Compare commits

..
12 Commits
Author SHA1 Message Date
warlock 64759ce17d founders card 2026-09-14 13:23:14 +00:00
warlock c5084170b8 show escrow accounts 2026-09-06 14:04:16 +00:00
warlock aca0c17e4d replay wip 2026-07-29 12:47:29 +05:30
warlock 6f7af751e1 account system and syslogs 2026-07-26 18:31:43 +00:00
warlock 540f6e0af1 tweaks 2026-07-13 13:55:56 +00:00
NextJS e324a95625 sync 2026-07-12 17:55:44 +00:00
NextJS 4ed4a10eae ip geolocation + ping analysis 2026-06-02 10:24:58 +00:00
NextJS dc8377177a analysis complete 2026-05-23 15:40:53 +00:00
NextJS 975cc5f1d9 analysis complete 2026-05-23 15:03:50 +00:00
warlock 9bb0af59b0 analysis WiP 2026-05-23 20:07:14 +05:30
warlock 0bb486223e add supply 2026-05-13 12:59:35 +05:30
warlock fcf7654e78 fixed matches fee calc 2026-05-12 16:32:05 +05:30
120 changed files with 13272 additions and 645 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"supabase": {
"enabled": true
}
}
}
+19
View File
@@ -1,3 +1,18 @@
# Admin panel bootstrap (server only). Used once to create the admin account
# in data/admin-accounts.json. After bootstrap, change the password in Settings.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
# Optional: HMAC secret for signed session cookies. Defaults to a value derived
# from ADMIN_PASSWORD when unset.
# ADMIN_SESSION_SECRET=
# Optional: absolute path for the accounts JSON file (default: <cwd>/data/admin-accounts.json)
# ADMIN_ACCOUNTS_PATH=
# Optional: absolute path for the panel audit log (default: <cwd>/data/admin-audit.jsonl)
# ADMIN_AUDIT_LOG_PATH=
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
# Example: https://kickkings.playpoolstudios.com
# APP_ORIGIN=
@@ -16,3 +31,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw).
# Example: /var/www/html/kickkings/logs/matchmaker/logs
# MATCHMAKER_LOGS_DIR=
# Third-party IP geolocation provider (ipgeolocation.io)
# Used for country code lookup from user IP and ping report IP.
IPGEOLOCATION_API_KEY=your-ipgeolocation-api-key
+6
View File
@@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# admin panel accounts (passwords hashed; still keep private)
/data/admin-accounts.json
/data/admin-audit.jsonl
/data/founders-card-presets/
/data/*.tmp
+205
View File
@@ -0,0 +1,205 @@
# Prompt: add KK Card Gen to this Next.js app
Implement the **KK Card Gen** feature inside **this** existing Next.js app. Do not create a second Next app. Do not iframe the old app. Merge it as a route + APIs + libs.
Source of truth if you can read files from disk: `F:\Projects\Node\kk_card_gen`
If you cannot access that folder, reimplement from this spec exactly.
## Product
Personal tool to stamp sequential user IDs onto a local base card image and download a zip of PNGs.
User flow:
1. Pick a base image (PNG/JPG/WebP, max 10MB) from disk or drop it.
2. Configure ID: prefix, suffix, start number, pad length 06, count 11000 (default 1000).
3. Style text: preset font or uploaded TTF/OTF, size, color, bold, italic, left/center/right.
4. Optional drop shadow: color, opacity, blur, offset X/Y.
5. Optional outline: color, opacity, width.
6. Position: drag the sample ID on the preview, click to place, or nudge X/Y %.
7. Click Generate. Server generates PNGs in parallel, then zips them.
8. UI shows live progress: `Generating 12 / 1000` then `Zipping 12 / 1000`.
9. When done, show a **Download ids.zip** link. Do **not** fetch the zip as a blob in JS and auto-download. That hung on the server.
10. Filenames = sanitized ID + `.png` (example `KK0001.png`).
No header/marketing chrome. No helper text like “Drop a card image with an empty ID area…”. Empty preview only needs “Select a base photo”.
## Hosting constraints (must follow)
- Route handlers: `export const runtime = "nodejs"` (never Edge).
- Native module `@napi-rs/canvas` + Node `worker_threads`.
- Host must be a real Node server (VPS / `next start`). Vercel-style serverless is a poor fit.
- `maxDuration = 300` on generate.
- Zip files live on disk under `os.tmpdir()/kk-card-jobs/<uuid>/` for 15 minutes so any Node worker on the same machine can serve the download. Do **not** store jobs in an in-memory `Map`.
## Dependencies
```bash
npm install @napi-rs/canvas archiver
npm install -D @types/archiver
```
`next.config` must include:
```ts
serverExternalPackages: ["@napi-rs/canvas"]
```
Merge with existing config. Do not remove other settings.
## Files to add
Adapt `@/` aliases if this repo uses `src/`. Keep the **worker file name and cwd-relative path** unless you update `generate-pool.ts` to match.
```
app/cards/page.tsx # or another unused route; render <Editor />
app/cards/editor.tsx # "use client" editor UI
app/api/generate/route.ts
app/api/download/[jobId]/route.ts
lib/id-config.ts
lib/draw-id.ts
lib/fonts.ts
lib/progress.ts
lib/jobs.ts
lib/create-zip.ts
lib/generate-pool.ts
lib/register-fonts.ts # optional if only workers register fonts
workers/generate-id.cjs # MUST be a real file on disk at repo root
public/fonts/Inter-Regular.ttf
public/fonts/Inter-Bold.ttf
public/fonts/Roboto-Regular.ttf
public/fonts/Roboto-Bold.ttf
public/fonts/Oswald-Regular.ttf
public/fonts/Oswald-Bold.ttf
public/fonts/Montserrat-Regular.ttf
public/fonts/Montserrat-Bold.ttf
public/fonts/PlayfairDisplay-Regular.ttf
public/fonts/PlayfairDisplay-Bold.ttf
```
If this app already has `app/page.tsx` you care about, **do not overwrite it**. Put the editor on `/cards` (or `/kk-card-gen`).
Copy fonts from `F:\Projects\Node\kk_card_gen\public\fonts` if possible. Otherwise download Inter / Roboto / Oswald / Montserrat / Playfair Display regular+bold TTF (latin 400 + 700) into those exact filenames.
Add matching `@font-face` rules to the apps global CSS so the **browser preview** uses the same files as the server. Families: `Inter`, `Roboto`, `Oswald`, `Montserrat`, `Playfair Display`.
## Shared drawing rules (preview MUST match export)
Both client canvas and worker canvas must use the same logic:
- Position: `x = xPercent/100 * width`, `y = yPercent/100 * height`
- `textBaseline = "alphabetic"`**never** `"middle"` (browser vs Skia disagree; export sat too low)
- Measure `actualBoundingBoxAscent/Descent` (fallback `fontSize * 0.8` / `0.2`)
- Alphabetic Y for visual center: `baselineY = y + (ascent - descent) / 2`
- Font string: `` `${italic?} ${bold?700:400} ${fontSize}px "${fontFamily}"` ``
- Outline: `strokeText` first (`lineJoin: "round"`), then clear shadow, then `fillText`
- Shadow via `shadowColor/Blur/OffsetX/OffsetY` (hex + opacity → rgba)
- Font size is pixels at **source image resolution**. Preview canvas internal size = naturalWidth/Height; CSS scales it. Mouse coords: map from `getBoundingClientRect()` to canvas pixels, then to percents.
ID string:
```ts
`${prefix}${String(n).padStart(pad, "0")}${suffix}`
```
Filename: strip `/ \ : * ? " < > |`, trim, fallback `"id"`.
Limits: count 11000, pad 06, start 01_000_000, fontSize 8400, image 10MB, font 5MB, hex colors `#rgb` / `#rrggbb` / `#rrggbbaa`.
Default config: prefix `KK`, start `1`, pad `4`, count `1000`, font `Inter`, size `48`, color `#111111`, align `center`, position 50/50, shadow/outline off.
Custom uploaded font family name: `CustomUpload` (FontFace in browser; `GlobalFonts.registerFromPath` in workers).
## Worker pool (required for speed)
Do **not** generate 1000 images on the Next.js request thread in a single loop.
`lib/generate-pool.ts`:
- Split tasks across `min(cpu, 8, taskCount)` workers
- Spawn with `createRequire(...).("node:worker_threads").Worker`**do not** `import { Worker } from "node:worker_threads"` then `new Worker(path)`. Turbopack/webpack intercepts `new Worker()` and breaks (`__dirname is not defined` / missing worker module).
- Worker path: `path.join(process.cwd(), "workers", "generate-id.cjs")`
- Each worker gets `{ imagePath, fonts: [{path,family}], style, tasks: [{text,outPath}] }`
- Worker posts `{ type: "progress" }` after each PNG
- Parent aggregates and streams generate progress
`workers/generate-id.cjs` must be **CommonJS** (`require("@napi-rs/canvas")`). ESM workers broke canvas. Worker: register fonts, `loadImage`, one canvas, draw, `encode("png")`, `writeFile`.
## Generate API — `POST /api/generate`
`multipart/form-data`:
- `image` File
- `font` File (only if custom)
- `config` JSON string of `GenerateConfig`
Validation errors: JSON `{ error }` with 4xx.
Success: `Content-Type: application/x-ndjson`, `Cache-Control: no-store`, `X-Accel-Buffering: no`.
Stream one JSON object per line:
```json
{"phase":"generate","current":12,"total":1000}
{"phase":"zip","current":12,"total":1000}
{"phase":"done","id":"<uuid>"}
{"phase":"error","message":"..."}
```
Pipeline:
1. `createJobDir()``{ id, dir, pngDir, zipPath }` under `tmpdir()/kk-card-jobs/<uuid>/`
2. Write base image to disk; write custom font if any
3. Build file list of `{ text, name, path }`
4. `generatePngsInWorkers` + stream generate events
5. Zip with `archiver` `ZipArchive` (`new ZipArchive({ zlib: { level: 6 } })`). `@types/archiver` has no default factory; import `{ ZipArchive } from "archiver"`.
6. Stream zip progress via archiver `progress`
7. `{"phase":"done","id"}` — zip stays on disk. Do not stream the zip in this response.
## Download API — `GET /api/download/[jobId]`
- Validate UUID (prevent path traversal)
- `stat` `ids.zip`; 404 if missing/empty
- Stream file with `Content-Type: application/zip`, `Content-Length`, `Content-Disposition: attachment; filename="ids.zip"`
- Do **not** delete the zip on first download (link should work if they click twice). TTL sweep on new jobs is enough.
- Next 16 params: `const { jobId } = await context.params`
## Editor UI
Client component. Two columns: preview left, controls right (base image, user ID, text style, drop shadow, outline, position, progress, download link, generate button).
Preview: canvas at natural image size, overlay sample ID (`prefix+pad(start)+suffix`), dashed selection box, drag/click-to-place.
Generate:
- POST FormData to `/api/generate`
- Read body as NDJSON (buffer split on `\n`)
- Update progress bar + button label (`Generating 12 / 1000`, `Zipping 12 / 1000`)
- On `done`, set `downloadHref = /api/download/${id}` and show `<a href={...} download="ids.zip">Download ids.zip</a>`
- Never `response.blob()` the zip
Match existing app look if there is a design system. If the app already has Tailwind, reuse it (dark zinc/indigo is fine). If not, add minimal CSS; do not force a full Tailwind install unless the app already has it.
Do not add a marketing header.
## Pitfalls
1. `textBaseline: "middle"` → generated text sits lower than preview.
2. Auto-download via `fetch` + `blob()` → hangs on server. Use a link.
3. In-memory job map → download 404/hang with multiple Node workers. Use disk + UUID.
4. `new Worker()` imported from `worker_threads` → Next bundles it and breaks. Use `createRequire` + `threads.Worker`.
5. Worker must be `.cjs` on disk at `workers/generate-id.cjs` relative to `process.cwd()` (the Next app root).
6. Preview fonts need `@font-face`; server fonts need TTF files under `public/fonts` with the names in `lib/fonts.ts`.
7. Do not put generate/download on Edge.
8. Archiver v8: `import { ZipArchive } from "archiver"` then `new ZipArchive(...)`.
## Verify before done
1. Typecheck / lint the new files.
2. Open the new route in the browser. No leftover header copy.
3. Upload a card, place the ID, generate **12** images. Confirm progress then a working **Download ids.zip** link. Unzip: `KK0001.png``KK0012.png`.
4. Confirm preview position matches a generated PNG (not shifted down).
5. Toggle shadow + outline and generate 1 image; both appear.
6. Confirm `/api/generate` is not Edge.
If `F:\Projects\Node\kk_card_gen` is readable, **copy those files and adapt imports/routes** rather than rewriting from scratch. Preserve worker + draw behavior.
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=KickKings Admin (Next.js)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=next
Group=next
WorkingDirectory=/home/next/kickkings_admin
Environment=NODE_ENV=production
Environment=PATH=/home/next/.nvm/versions/node/v24.13.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/home/next/.nvm/versions/node/v24.13.0/bin/npm run start
Restart=on-failure
RestartSec=5
# Hardening (optional; remove if the service fails to start)
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["@napi-rs/canvas"],
};
export default nextConfig;
+1183 -185
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -3,25 +3,34 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 2613",
"dev": "next dev -p 2614",
"build": "next build",
"start": "next start -p 2613",
"lint": "eslint"
},
"dependencies": {
"@napi-rs/canvas": "^1.0.9",
"@supabase/supabase-js": "^2.102.1",
"next": "^16.2.4",
"archiver": "^8.0.0",
"next": "^16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-simple-maps": "^3.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/archiver": "^8.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
"d3-color": "^3.1.0",
"postcss": "^8.5.10"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
create table public.early_player_emails (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
email text not null,
preset text not null default 'founder-card-5',
constraint early_player_emails_pkey primary key (id),
constraint early_player_emails_email_key unique (email),
constraint early_player_emails_email_len check (char_length(email) between 3 and 254),
constraint early_player_emails_preset_len check (char_length(preset) between 1 and 80)
);
-- Row `id` is the founder card number stamped onto the PNG.
-- Access is service-role only (RLS on, no policies; anon/authenticated revoked).
+11
View File
@@ -0,0 +1,11 @@
create table public.ping_reports (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
user_id bigint not null,
match_id bigint not null,
ip_address text null,
ping integer not null,
constraint ping_reports_pkey primary key (id),
constraint ping_reports_user_id_fkey foreign key (user_id) references users (id),
constraint ping_reports_match_id_fkey foreign key (match_id) references matches (id)
) TABLESPACE pg_default;
+291
View File
@@ -0,0 +1,291 @@
# Match Replay JSON Format
Guide for implementing the admin-panel replay viewer. Files are written by the Unity dedicated server (and practice host) to disk only.
## Where files live
```
{ApplicationDirectory}/Logs/{matchId}_replay.json
```
| Match type | `matchId` in filename / JSON | Notes |
|------------|------------------------------|--------|
| Ranked / dedicated | Positive matchmaker id (e.g. `228`) | Same id as `Logs/228.txt` |
| Practice / tutorial | Negative unix timestamp (e.g. `-1769539200`) | `isPractice: true`; unique per session |
Example paths:
- `Logs/228_replay.json`
- `Logs/-1769539200_replay.json`
## Top-level object
```json
{
"version": 1,
"matchId": 228,
"isPractice": false,
"fixedDeltaTime": 0.02,
"duration": 184.32,
"entities": [ /* ... */ ],
"frames": [ /* ... */ ],
"events": [ /* ... */ ]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `version` | int | Schema version. Currently `1`. |
| `matchId` | int | Match id (positive = ranked, negative = practice). |
| `isPractice` | bool | `true` for localhost practice / tutorial host. |
| `fixedDeltaTime` | float | Physics step used while recording (typically `0.02` → ~50 Hz). |
| `duration` | float | Length of the recording in seconds (`t` of last sample / end). |
| `entities` | array | Ball + pucks. Order defines pose array indices in every frame. |
| `frames` | array | Time-ordered pose samples (~every FixedUpdate). |
| `events` | array | Time-ordered discrete events (hits, goals, resets). |
Time `t` everywhere is **seconds since recording start** (match start), not wall-clock UTC.
---
## Entities
```json
{ "id": 0, "type": "ball", "team": "" }
{ "id": 1, "type": "puck", "team": "Red" }
{ "id": 2, "type": "puck", "team": "Blue" }
```
| Field | Type | Values |
|-------|------|--------|
| `id` | int | Stable index `0 .. N-1`. Matches array index in each frames `x`/`y`/`vx`/`vy`. |
| `type` | string | `"ball"` or `"puck"` |
| `team` | string | `"Red"`, `"Blue"`, or `""` for ball |
Entity list is fixed for the whole file. **Index `i` in a frames pose arrays always refers to `entities[i]`.**
Typical order: ball first, then pucks sorted by team then spawn position.
---
## Frames (motion samples)
```json
{
"t": 12.40,
"x": [0.0, 1.2, -0.5],
"y": [0.1, -0.3, 2.0],
"vx": [0.0, 3.1, -1.0],
"vy": [0.2, 0.0, 0.5]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `t` | float | Sample time in seconds (monotonic, increasing). |
| `x`, `y` | float[] | World position per entity (Unity 2D world units). |
| `vx`, `vy` | float[] | Linear velocity per entity at that sample (world units / second). |
- Array length always equals `entities.length`.
- Samples are dense (~`1 / fixedDeltaTime` Hz). Do **not** treat them as render frames.
### Coordinate system
Unity 2D world space:
- Origin at field center
- `+X` right, `+Y` up (as in Unity)
- Scale matches the in-game pitch (see game `fieldSize` / art for admin canvas mapping)
---
## Events
```json
{ "t": 12.4, "type": "hit", "kind": "ball_wall", "vol": 0.6, "id": 0, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
{ "t": 30.1, "type": "goal", "kind": "", "vol": 0, "id": -1, "team": "Blue", "redScore": 1, "blueScore": 0, "kickoff": false }
{ "t": 32.2, "type": "reset", "kind": "", "vol": 0, "id": -1, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
```
All event objects share the same fields (Unity `JsonUtility` shape). Unused fields are empty / zero / `-1`.
| Field | Used by | Description |
|-------|---------|-------------|
| `t` | all | Event time in seconds. |
| `type` | all | `"hit"` \| `"goal"` \| `"reset"` |
| `kind` | `hit` | `"puck_puck"` \| `"ball_puck"` \| `"ball_wall"` |
| `vol` | `hit` | SFX volume `0..1` (from relative impact speed). |
| `id` | `hit` | Entity id involved (spatial cue). `-1` if unknown. |
| `team` | `goal` | **Conceding** side / goal owner (`"Red"` or `"Blue"`). The other team scored. |
| `redScore` | `goal` | Score after this goal. |
| `blueScore` | `goal` | Score after this goal. |
| `kickoff` | `goal`, `reset` | Kickoff-goal / kickoff-style reset when `true`. |
### Event semantics
| `type` | Meaning for the viewer |
|--------|-------------------------|
| `hit` | Play collision SFX at `vol`. Optional: flash near entity `id`. |
| `goal` | Goal scored against `team`. Update scoreboard to `redScore`/`blueScore`. Play goal SFX. **Discontinuity** — do not interpolate motion across this time. |
| `reset` | Pieces are lerping / teleporting back. **Discontinuity** — snap poses; do not blend across. |
First-to-3 wins; a completed match usually ends shortly after a goal that makes a score `3`.
---
## Playback contract (required for smooth motion)
Recording is FixedUpdate (~50 Hz). Snapping happens if the viewer steps sample-by-sample without blending. Follow this:
### 1. Drive time with wall clock
```text
playbackTime += (deltaMs / 1000) * playbackSpeed
playbackTime = clamp(playbackTime, 0, duration)
```
Use `requestAnimationFrame` (or equivalent). Do **not** advance one JSON frame per rAF tick.
### 2. Interpolate poses between samples
Binary-search `frames` for neighbors where:
```text
frames[i].t <= playbackTime < frames[i+1].t
```
Then for each entity index `e`:
**Minimum (good):** linear lerp of `x`/`y`:
```text
u = (playbackTime - t0) / (t1 - t0)
x = lerp(x0[e], x1[e], u)
y = lerp(y0[e], y1[e], u)
```
**Recommended (smooth under acceleration):** cubic Hermite using velocities:
```text
dt = t1 - t0
u = (playbackTime - t0) / dt
u2 = u*u
u3 = u2*u
h00 = 2*u3 - 3*u2 + 1
h10 = u3 - 2*u2 + u
h01 = -2*u3 + 3*u2
h11 = u3 - u2
x = h00*x0 + h10*dt*vx0 + h01*x1 + h11*dt*vx1
y = h00*y0 + h10*dt*vy0 + h01*y1 + h11*dt*vy1
```
(Same for each entity index.)
### 3. Snap across discontinuities
Treat every `goal` and `reset` event as a cut:
- When `playbackTime` crosses that events `t`, **do not** interpolate from the previous sample across the cut.
- Seek to the frame at or immediately after `t` and snap positions.
Practical approach: build a sorted list of discontinuity times from events where `type === "goal" || type === "reset"`. If `t0` and `t1` straddle a discontinuity, snap to the post-cut sample instead of lerping.
### 4. Fire events once when crossed
Keep `lastEventIndex` (or last fired `t`). When `playbackTime` advances past an events `t`, fire it once:
- `hit` → SFX by `kind` / `vol`
- `goal` → score UI + goal audio
- `reset` → optional reset cue (usually silent)
On seek / scrub: reset event cursor; either skip SFX or replay only from the new time forward.
---
## Suggested viewer pipeline
```text
Load JSON
→ map entities to sprites (ball / red pucks / blue pucks)
→ sort events by t (already ordered, but verify)
→ each animation frame:
advance playbackTime
resolve poses (Hermite + discontinuity snaps)
draw entities
dispatch newly crossed events
```
### Seeking / scrubbing
1. Set `playbackTime` to the scrub value.
2. Find the nearest frame (or interpolate as above).
3. Recompute score from the last `goal` event with `t <= playbackTime` (else `00`).
4. Reset the event-fire cursor to that time so SFX dont spam.
### Scoreboard without scrubbing
Start at `00`. On each `goal` event, set scores from that events `redScore` / `blueScore`.
---
## Minimal TypeScript types
```ts
export interface ReplayFile {
version: number;
matchId: number;
isPractice: boolean;
fixedDeltaTime: number;
duration: number;
entities: ReplayEntity[];
frames: ReplayFrame[];
events: ReplayEvent[];
}
export interface ReplayEntity {
id: number;
type: "ball" | "puck" | string;
team: "Red" | "Blue" | "" | string;
}
export interface ReplayFrame {
t: number;
x: number[];
y: number[];
vx: number[];
vy: number[];
}
export interface ReplayEvent {
t: number;
type: "hit" | "goal" | "reset" | string;
kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
vol: number;
id: number; // entity id, or -1
team: "Red" | "Blue" | "" | string; // conceding team on goal
redScore: number;
blueScore: number;
kickoff: boolean;
}
```
---
## Checklist for a correct viewer
- [ ] Time driven by real elapsed time × speed, not frame index
- [ ] Poses interpolated between samples (Hermite preferred)
- [ ] No interpolation across `goal` / `reset`
- [ ] Entity index `i` always matches `entities[i]`
- [ ] Hit SFX use `kind` + `vol`
- [ ] Goal `team` means **conceding** side
- [ ] Practice files filtered via `isPractice` or negative `matchId`
## Out of scope (not in the JSON)
- Player names / user ids (use matchmaker / match API by `matchId` for ranked games)
- Camera / UI layout
- Launch aim lines / turn timer
- Network latency or client prediction
+2
View File
@@ -2,7 +2,9 @@ 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,
email text null,
password text null,
ip_address text null,
cc real null default '0'::real,
rc real null default '0'::real,
last_logged_at timestamp with time zone null default now(),
+161
View File
@@ -0,0 +1,161 @@
"use server";
import { redirect } from "next/navigation";
import {
createAccount,
deleteAccount,
findAccountById,
updateAccount,
} from "@/lib/auth/accounts-store";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
normalizePagePermissions,
PAGE_KEYS,
type PageKey,
type PagePermissions,
} from "@/lib/auth/permissions";
import { requireAdmin } from "@/lib/auth/require-session";
function parsePermissionsFromForm(formData: FormData): PagePermissions {
const raw: Partial<Record<PageKey, { read: boolean; write: boolean }>> = {};
for (const key of PAGE_KEYS) {
raw[key] = {
read: formData.get(`perm_${key}_read`) === "1",
write: formData.get(`perm_${key}_write`) === "1",
};
}
return normalizePagePermissions(raw);
}
export async function createAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const permissions = parsePermissionsFromForm(formData);
if (!username) {
redirect("/settings?accountError=missingUsername");
}
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await createAccount({ username, password, permissions });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.create",
summary: `Created account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=created");
}
export async function updateAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const isAdminRow = formData.get("isAdmin") === "1";
const password = String(formData.get("password") ?? "");
const usernameRaw = String(formData.get("username") ?? "");
if (isAdminRow) {
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await updateAccount({ id, password });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Changed password for admin “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
passwordChanged: true,
},
});
redirect("/settings?accountOk=updated");
}
const permissions = parsePermissionsFromForm(formData);
const result = await updateAccount({
id,
username: usernameRaw,
password: password || undefined,
permissions,
});
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Updated account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
previousUsername: existing.username,
passwordChanged: Boolean(password),
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=updated");
}
export async function deleteAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const result = await deleteAccount(id);
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.delete",
summary: `Deleted account “${existing.username}`,
details: {
targetId: existing.id,
targetUsername: existing.username,
},
});
redirect("/settings?accountOk=deleted");
}
+167
View File
@@ -0,0 +1,167 @@
"use server";
import { redirect } from "next/navigation";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import { createAdminSupabase } from "@/lib/supabase/admin";
import { normalizeLedgerPageSize } from "@/lib/ledger-table-view";
const SYSTEM_ACCOUNT_ID = 1;
function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!/^[1-9]\d*$/.test(s)) return null;
try {
return BigInt(s);
} catch {
return null;
}
}
export async function addSystemSupply(formData: FormData) {
const actor = await requirePageWrite("ledger");
const tabRaw = String(formData.get("tab") ?? "");
const tab: AdminDashboardTab =
tabRaw === "matches"
? "matches"
: tabRaw === "players"
? "players"
: tabRaw === "matchmaker"
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: tabRaw === "analysis"
? "analysis"
: "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 ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim();
const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim();
const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim();
const ledgerPageSizeRaw = String(
formData.get("ledgerPageSize") ?? "",
).trim();
const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim();
const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim();
const ledgerPageNum =
ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10);
const base = {
tab,
highlightId,
participantRaw: participant,
...(tab === "ledger"
? {
ledgerFrom: ledgerFromRaw || null,
ledgerTo: ledgerToRaw || null,
ledgerPage:
Number.isInteger(ledgerPageNum) && ledgerPageNum > 1
? ledgerPageNum
: null,
ledgerSort: ledgerSortRaw || null,
ledgerOrder:
ledgerOrderRaw === "desc"
? ("desc" as const)
: ledgerOrderRaw === "asc"
? ("asc" as const)
: null,
ledgerPageSize: normalizeLedgerPageSize(
ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw,
),
}
: {}),
};
const parsedCoins = parsePositiveCoins(formData.get("supplyAmount"));
if (parsedCoins == null) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const coins = parsedCoins;
const supabase = createAdminSupabase();
if (!supabase) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const { data: sysRow, error: fetchErr } = await supabase
.from("users")
.select("id, rc")
.eq("id", SYSTEM_ACCOUNT_ID)
.maybeSingle();
if (fetchErr || !sysRow) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const currentRc = sysRow.rc as number | null;
const { error: insertErr } = await supabase.from("transactions").insert({
from: null,
to: SYSTEM_ACCOUNT_ID,
amount: coins.toString(),
remarks: "supply",
match_id: null,
});
if (insertErr) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const newRc = applyCoinsDeltaToRcBalance(currentRc, coins);
const { error: updateErr } = await supabase
.from("users")
.update({ rc: newRc })
.eq("id", SYSTEM_ACCOUNT_ID);
if (updateErr) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "ledger.system_supply",
summary: `Added system supply of ${coins.toString()} coins`,
details: { coins: coins.toString(), systemAccountId: SYSTEM_ACCOUNT_ID },
});
redirect(buildDashboardHref(base));
}
+62 -18
View File
@@ -1,32 +1,34 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requireAdmin } from "@/lib/auth/require-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;
const EMPTY = "__empty__";
const INVALID = "__invalid__";
function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
const s = raw == null ? "" : String(raw).trim();
return s === "" ? EMPTY : s;
}
function normalizeSettingValue(
key: string,
raw: FormDataEntryValue | null,
): string | null {
): string | typeof EMPTY | typeof INVALID {
const s = raw == null ? "" : String(raw).trim();
if (key === "entry_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const coins = parseStoredCoins(s);
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
return String(coins);
}
if (key === "bet_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const n = Math.round(Number(s));
if (!Number.isFinite(n)) return "__invalid__";
return String(Math.min(100, Math.max(0, n)));
@@ -36,10 +38,7 @@ function normalizeSettingValue(
}
export async function updateSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim();
if (!key) {
@@ -47,7 +46,7 @@ export async function updateSetting(formData: FormData) {
}
const value = normalizeSettingValue(key, formData.get("value"));
if (value === "__invalid__") {
if (value === INVALID || value === EMPTY) {
redirect("/settings?saveError=1");
}
@@ -65,14 +64,19 @@ export async function updateSetting(formData: FormData) {
redirect("/settings?saveError=1");
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.update",
summary: `Updated setting “${key}`,
details: { key, value },
});
redirect("/settings");
}
export async function insertSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requireAdmin();
const key = String(formData.get("newKey") ?? "").trim();
const value = normalizeValue(formData.get("newValue"));
@@ -80,6 +84,9 @@ export async function insertSetting(formData: FormData) {
if (!key) {
redirect("/settings?addError=missing");
}
if (value === EMPTY) {
redirect("/settings?addError=missingValue");
}
const supabase = createAdminSupabase();
if (!supabase) {
@@ -93,5 +100,42 @@ export async function insertSetting(formData: FormData) {
redirect(`/settings?addError=${code}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.insert",
summary: `Created setting “${key}`,
details: { key, value },
});
redirect("/settings");
}
export async function deleteSetting(formData: FormData) {
const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim();
if (!key) {
redirect("/settings?saveError=1");
}
const supabase = createAdminSupabase();
if (!supabase) {
redirect("/settings?saveError=1");
}
const { error } = await supabase.from("settings").delete().eq("key", key);
if (error) {
redirect("/settings?saveError=1");
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.delete",
summary: `Deleted setting “${key}`,
details: { key },
});
redirect("/settings");
}
+14 -7
View File
@@ -1,8 +1,8 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import {
buildDashboardHref,
type AdminDashboardTab,
@@ -19,10 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
}
export async function updateUserCcRc(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requirePageWrite("players");
const userId = Number(formData.get("userId"));
const tabRaw = String(formData.get("tab") ?? "");
@@ -35,7 +32,9 @@ export async function updateUserCcRc(formData: FormData) {
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: "dashboard";
: tabRaw === "analysis"
? "analysis"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
const highlightId = highlightRaw === "" ? null : highlightRaw;
@@ -125,5 +124,13 @@ export async function updateUserCcRc(formData: FormData) {
);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "players.update_cc_rc",
summary: `Updated CC/RC for player #${userId}`,
details: { userId, cc, rc },
});
redirect(buildDashboardHref(base));
}
+69 -4
View File
@@ -1,4 +1,18 @@
import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip";
import { verifyCredentials } from "@/lib/auth/credentials";
import { createSessionToken } from "@/lib/auth/roles";
import {
checkLoginRateLimit,
clearLoginAttempts,
rateLimitedLoginResponse,
recordFailedLogin,
} from "@/lib/auth/login-rate-limit";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import {
applySessionCookie,
@@ -10,8 +24,26 @@ function invalidRedirect(request: Request) {
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
}
function invalidJson() {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
const rateLimit = checkLoginRateLimit(clientIp);
if (!rateLimit.allowed) {
return rateLimitedLoginResponse(
request,
contentType,
rateLimit.retryAfterSeconds,
publicRequestUrl,
);
}
let username: string;
let password: string;
@@ -31,24 +63,57 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? "");
}
if (username !== "admin" || password !== "admin") {
let account;
try {
account = await verifyCredentials(username, password);
} catch {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
return NextResponse.json(
{ error: "Admin account is not configured" },
{ status: 503 },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=1"),
);
}
if (!account) {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return invalidJson();
}
return invalidRedirect(request);
}
clearLoginAttempts(clientIp);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.login",
summary: `Signed in from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: {
ip: clientIp,
device,
userAgent,
},
});
if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true });
res.cookies.set(
ADMIN_SESSION_COOKIE,
"1",
createSessionToken(account.username),
getSessionCookieSetOptions(request),
);
return res;
}
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
applySessionCookie(res, request);
applySessionCookie(res, request, account.username);
return res;
}
+25
View File
@@ -1,8 +1,33 @@
import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip";
import { getSessionAccount } from "@/lib/auth/require-session";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
export async function POST(request: Request) {
const account = await getSessionAccount();
if (account) {
const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.logout",
summary: `Signed out from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: { ip: clientIp, device, userAgent },
});
}
const res = NextResponse.json({ ok: true });
res.cookies.set(
ADMIN_SESSION_COOKIE,
+48
View File
@@ -0,0 +1,48 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { isValidJobId, jobPaths, sweepExpiredJobs } from "@/lib/card-gen/jobs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
context: { params: Promise<{ jobId: string }> },
) {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "founders-card")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await context.params;
if (!isValidJobId(jobId)) {
return Response.json({ error: "Not found" }, { status: 404 });
}
await sweepExpiredJobs();
const { zipPath } = jobPaths(jobId);
let size = 0;
try {
const st = await stat(zipPath);
size = st.size;
} catch {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (!size) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const nodeStream = createReadStream(zipPath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new Response(webStream, {
headers: {
"Content-Type": "application/zip",
"Content-Length": String(size),
"Content-Disposition": 'attachment; filename="ids.zip"',
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,46 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { getLoadedPreset, isPresetId } from "@/lib/card-gen/preset-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
context: { params: Promise<{ presetId: string }> },
) {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "founders-card")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { presetId } = await context.params;
if (!isPresetId(presetId)) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const preset = await getLoadedPreset(presetId);
if (!preset?.fontPath) {
return Response.json({ error: "Not found" }, { status: 404 });
}
let size = 0;
try {
size = (await stat(preset.fontPath)).size;
} catch {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (!size) return Response.json({ error: "Not found" }, { status: 404 });
const stream = Readable.toWeb(
createReadStream(preset.fontPath),
) as ReadableStream<Uint8Array>;
const name = (preset.fontName ?? "custom.ttf").replace(/"/g, "");
return new Response(stream, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="${name}"`,
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,50 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { getLoadedPreset, isPresetId } from "@/lib/card-gen/preset-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
context: { params: Promise<{ presetId: string }> },
) {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "founders-card")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { presetId } = await context.params;
if (!isPresetId(presetId)) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const preset = await getLoadedPreset(presetId);
if (!preset) return Response.json({ error: "Not found" }, { status: 404 });
let size = 0;
try {
size = (await stat(preset.imagePath)).size;
} catch {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (!size) return Response.json({ error: "Not found" }, { status: 404 });
const stream = Readable.toWeb(
createReadStream(preset.imagePath),
) as ReadableStream<Uint8Array>;
const ext = preset.imageFile.split(".").pop()?.toLowerCase() ?? "png";
const type =
ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "webp"
? "image/webp"
: "image/png";
return new Response(stream, {
headers: {
"Content-Type": type,
"Content-Length": String(size),
"Content-Disposition": `inline; filename="${preset.imageName.replace(/"/g, "")}"`,
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,26 @@
import { getSessionAccount } from "@/lib/auth/require-session";
import { canWritePage } from "@/lib/auth/permissions";
import {
deleteStoredPreset,
isPresetId,
} from "@/lib/card-gen/preset-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function DELETE(
_request: Request,
context: { params: Promise<{ presetId: string }> },
) {
const account = await getSessionAccount();
if (!account || !canWritePage(account, "founders-card")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { presetId } = await context.params;
if (!isPresetId(presetId)) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const ok = await deleteStoredPreset(presetId);
if (!ok) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({ ok: true });
}
+142
View File
@@ -0,0 +1,142 @@
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage, canWritePage } from "@/lib/auth/permissions";
import {
CUSTOM_FONT_FAMILY,
isFontFile,
isImageFile,
LIMITS,
parseGenerateConfig,
} from "@/lib/card-gen/id-config";
import { PRESET_FONT_FAMILIES } from "@/lib/card-gen/fonts";
import {
listPresetMeta,
saveStoredPreset,
type PresetMeta,
} from "@/lib/card-gen/preset-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function jsonError(message: string, status: number) {
return Response.json({ error: message }, { status });
}
function toPublicMeta(meta: PresetMeta) {
return {
id: meta.id,
name: meta.name,
config: meta.config,
imageName: meta.imageName,
hasImage: Boolean(meta.imageFile),
fontName: meta.fontName,
hasFont: Boolean(meta.fontFile),
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
};
}
async function fileBytes(
file: File,
): Promise<{ bytes: Buffer; name: string; type: string }> {
return {
bytes: Buffer.from(await file.arrayBuffer()),
name: file.name,
type: file.type,
};
}
export async function GET() {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "founders-card")) {
return jsonError("Unauthorized", 401);
}
const presets = await listPresetMeta();
return Response.json({ presets: presets.map(toPublicMeta) });
}
export async function POST(request: Request) {
const account = await getSessionAccount();
if (!account || !canWritePage(account, "founders-card")) {
return jsonError("Unauthorized", 401);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return jsonError("Invalid multipart body", 400);
}
const name = String(form.get("name") ?? "").trim();
if (!name) return jsonError("Preset name is required", 400);
const idRaw = String(form.get("id") ?? "").trim();
const id = idRaw || undefined;
const configRaw = form.get("config");
if (typeof configRaw !== "string") return jsonError("Config is required", 400);
let parsedJson: unknown;
try {
parsedJson = JSON.parse(configRaw);
} catch {
return jsonError("Config must be JSON", 400);
}
const config = parseGenerateConfig(parsedJson);
if (!config) return jsonError("Invalid config", 400);
if (
config.fontFamily !== CUSTOM_FONT_FAMILY &&
!PRESET_FONT_FAMILIES.includes(config.fontFamily)
) {
return jsonError("Unknown font family", 400);
}
const image = form.get("image");
let imageInput: { bytes: Buffer; name: string; type: string } | null = null;
if (image instanceof File && image.size > 0) {
if (image.size > LIMITS.imageBytes) {
return jsonError("Image must be 10MB or smaller", 400);
}
if (!isImageFile(image)) {
return jsonError("Image must be PNG, JPG, or WebP", 400);
}
imageInput = await fileBytes(image);
}
const font = form.get("font");
let fontInput: { bytes: Buffer; name: string; type: string } | null = null;
if (font instanceof File && font.size > 0) {
if (font.size > LIMITS.fontBytes) {
return jsonError("Font must be 5MB or smaller", 400);
}
if (!isFontFile(font)) return jsonError("Font must be TTF or OTF", 400);
fontInput = await fileBytes(font);
}
if (config.fontFamily === CUSTOM_FONT_FAMILY && !fontInput) {
const clearFont = String(form.get("clearFont") ?? "") === "1";
if (clearFont && !id) {
return jsonError("Custom font file is required", 400);
}
}
try {
const saved = await saveStoredPreset({
id,
name,
config,
image: imageInput,
font: fontInput,
clearFont: !fontInput && String(form.get("clearFont") ?? "") === "1",
});
return Response.json(toPublicMeta(saved));
} catch (err) {
const message = err instanceof Error ? err.message : "Could not save preset";
const status =
message === "A preset with that name already exists" ||
message === "Image is required" ||
message === "Preset name is required"
? 400
: 500;
return jsonError(message, status);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { getClientIp } from "@/lib/auth/client-ip";
import { FOUNDERS_CARD_CORS, foundersCardJsonError } from "@/lib/card-gen/cors";
import { checkPublicCardRateLimit } from "@/lib/card-gen/public-rate-limit";
import { pngResponse, renderFoundersCardPng } from "@/lib/card-gen/render-public";
export const runtime = "nodejs";
export const maxDuration = 60;
export const dynamic = "force-dynamic";
export async function OPTIONS() {
return new Response(null, { status: 204, headers: FOUNDERS_CARD_CORS });
}
export async function GET(request: Request) {
const ip = getClientIp(request);
const rate = checkPublicCardRateLimit(ip);
if (!rate.allowed) {
return Response.json(
{ error: "Too many requests. Try again later." },
{
status: 429,
headers: {
...FOUNDERS_CARD_CORS,
"Retry-After": String(rate.retryAfterSeconds),
"Cache-Control": "no-store",
},
},
);
}
const url = new URL(request.url);
const presetName = url.searchParams.get("preset")?.trim() ?? "";
const idRaw = url.searchParams.get("id")?.trim() ?? "";
if (!presetName) return foundersCardJsonError("preset is required", 400);
if (!idRaw) return foundersCardJsonError("id is required", 400);
if (!/^\d+$/.test(idRaw)) return foundersCardJsonError("id must be a number", 400);
const rendered = await renderFoundersCardPng(presetName, Number(idRaw));
if (!rendered.ok) {
return foundersCardJsonError(rendered.error, rendered.status);
}
return pngResponse(rendered.png, rendered.filename, {
...FOUNDERS_CARD_CORS,
"Cache-Control": "public, max-age=60",
"X-Founder-Id": String(Number(idRaw)),
});
}
+99
View File
@@ -0,0 +1,99 @@
import { getClientIp } from "@/lib/auth/client-ip";
import { FOUNDERS_CARD_CORS, foundersCardJsonError } from "@/lib/card-gen/cors";
import {
normalizeSignupEmail,
upsertEarlyPlayerEmail,
} from "@/lib/card-gen/early-player-emails";
import { checkPublicSignupRateLimit } from "@/lib/card-gen/public-rate-limit";
import {
DEFAULT_SIGNUP_PRESET,
pngResponse,
renderFoundersCardPng,
} from "@/lib/card-gen/render-public";
export const runtime = "nodejs";
export const maxDuration = 60;
export const dynamic = "force-dynamic";
export async function OPTIONS() {
return new Response(null, { status: 204, headers: FOUNDERS_CARD_CORS });
}
async function readBody(
request: Request,
): Promise<{ email: string; preset: string }> {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const body = (await request.json()) as {
email?: unknown;
preset?: unknown;
};
return {
email: typeof body.email === "string" ? body.email : "",
preset: typeof body.preset === "string" ? body.preset : "",
};
}
const form = await request.formData();
return {
email: String(form.get("email") ?? ""),
preset: String(form.get("preset") ?? ""),
};
}
export async function POST(request: Request) {
const ip = getClientIp(request);
const rate = checkPublicSignupRateLimit(ip);
if (!rate.allowed) {
return Response.json(
{ error: "Too many requests. Try again later." },
{
status: 429,
headers: {
...FOUNDERS_CARD_CORS,
"Retry-After": String(rate.retryAfterSeconds),
"Cache-Control": "no-store",
},
},
);
}
let emailRaw = "";
let presetRaw = "";
try {
const body = await readBody(request);
emailRaw = body.email;
presetRaw = body.preset;
} catch {
return foundersCardJsonError("Invalid request body", 400);
}
const email = normalizeSignupEmail(emailRaw);
if (!email) return foundersCardJsonError("A valid email is required", 400);
const preset = (presetRaw.trim() || DEFAULT_SIGNUP_PRESET).slice(0, 80);
const saved = await upsertEarlyPlayerEmail(email, preset);
if ("error" in saved) {
return foundersCardJsonError(saved.error, saved.status);
}
const rendered = await renderFoundersCardPng(preset, saved.id);
if (!rendered.ok) {
return Response.json(
{ error: rendered.error, id: saved.id },
{
status: rendered.status,
headers: {
...FOUNDERS_CARD_CORS,
"Cache-Control": "no-store",
"X-Founder-Id": String(saved.id),
},
},
);
}
return pngResponse(rendered.png, rendered.filename, {
...FOUNDERS_CARD_CORS,
"X-Founder-Id": String(saved.id),
});
}
@@ -0,0 +1,20 @@
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { listEarlyPlayerEmails } from "@/lib/card-gen/early-player-emails";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "founders-card")) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await listEarlyPlayerEmails();
if ("error" in result) {
return Response.json({ error: result.error }, { status: 500 });
}
return Response.json({ emails: result.rows });
}
+169
View File
@@ -0,0 +1,169 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { createZipFromDir } from "@/lib/card-gen/create-zip";
import { extFromFile } from "@/lib/card-gen/file-ext";
import {
CUSTOM_FONT_FAMILY,
formatId,
isFontFile,
isImageFile,
LIMITS,
parseGenerateConfig,
pickDrawStyle,
sanitizeFilename,
} from "@/lib/card-gen/id-config";
import { fontEntriesForFamily } from "@/lib/card-gen/font-files";
import { PRESET_FONT_FAMILIES } from "@/lib/card-gen/fonts";
import { generatePngsInWorkers } from "@/lib/card-gen/generate-pool";
import { createJobDir } from "@/lib/card-gen/jobs";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canWritePage } from "@/lib/auth/permissions";
export const runtime = "nodejs";
export const maxDuration = 300;
export const dynamic = "force-dynamic";
function jsonError(message: string, status: number) {
return Response.json({ error: message }, { status });
}
export async function POST(request: Request) {
const account = await getSessionAccount();
if (!account || !canWritePage(account, "founders-card")) {
return jsonError("Unauthorized", 401);
}
let form: FormData;
try {
form = await request.formData();
} catch {
return jsonError("Invalid multipart body", 400);
}
const image = form.get("image");
if (!(image instanceof File) || image.size === 0) {
return jsonError("Image is required", 400);
}
if (image.size > LIMITS.imageBytes) {
return jsonError("Image must be 10MB or smaller", 400);
}
if (!isImageFile(image)) {
return jsonError("Image must be PNG, JPG, or WebP", 400);
}
const configRaw = form.get("config");
if (typeof configRaw !== "string") {
return jsonError("Config is required", 400);
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(configRaw);
} catch {
return jsonError("Config must be JSON", 400);
}
const config = parseGenerateConfig(parsedJson);
if (!config) {
return jsonError("Invalid config", 400);
}
const font = form.get("font");
let fontFile: File | null = null;
if (font instanceof File && font.size > 0) {
if (font.size > LIMITS.fontBytes) {
return jsonError("Font must be 5MB or smaller", 400);
}
if (!isFontFile(font)) {
return jsonError("Font must be TTF or OTF", 400);
}
fontFile = font;
}
if (config.fontFamily === CUSTOM_FONT_FAMILY && !fontFile) {
return jsonError("Custom font file is required", 400);
}
if (
config.fontFamily !== CUSTOM_FONT_FAMILY &&
!PRESET_FONT_FAMILIES.includes(config.fontFamily)
) {
return jsonError("Unknown font family", 400);
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (obj: unknown) => {
controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`));
};
try {
const job = await createJobDir();
const imagePath = path.join(
job.dir,
`base${extFromFile(image, ".png")}`,
);
await writeFile(imagePath, Buffer.from(await image.arrayBuffer()));
let customFontPath: string | null = null;
if (fontFile) {
customFontPath = path.join(
job.dir,
`custom${extFromFile(fontFile, ".ttf")}`,
);
await writeFile(
customFontPath,
Buffer.from(await fontFile.arrayBuffer()),
);
}
const fonts = fontEntriesForFamily(config.fontFamily, customFontPath);
if (fonts.length === 0) {
send({ phase: "error", message: "Could not resolve fonts" });
controller.close();
return;
}
const tasks = [];
for (let i = 0; i < config.count; i += 1) {
const n = config.start + i;
const text = formatId(config.prefix, n, config.pad, config.suffix);
const name = `${sanitizeFilename(text)}.png`;
tasks.push({
text,
outPath: path.join(job.pngDir, name),
});
}
send({ phase: "generate", current: 0, total: tasks.length });
await generatePngsInWorkers({
imagePath,
fonts,
style: pickDrawStyle(config),
tasks,
onProgress: (current, total) => {
send({ phase: "generate", current, total });
},
});
send({ phase: "zip", current: 0, total: tasks.length });
await createZipFromDir(job.pngDir, job.zipPath, (current, total) => {
send({ phase: "zip", current, total });
});
send({ phase: "done", id: job.id });
controller.close();
} catch (err) {
const message =
err instanceof Error ? err.message : "Generate failed";
send({ phase: "error", message });
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson",
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
});
}
+7 -4
View File
@@ -1,5 +1,5 @@
import { cookies } from "next/headers";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import {
parseMatchIdParam,
readMatchLogFile,
@@ -9,8 +9,11 @@ export async function GET(
_request: Request,
context: { params: Promise<{ matchId: string }> },
) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
const account = await getSessionAccount();
if (
!account ||
(!canReadPage(account, "matches") && !canReadPage(account, "analysis"))
) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+4 -4
View File
@@ -1,13 +1,13 @@
import { cookies } from "next/headers";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
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") {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "matchmaker")) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401, headers: NO_STORE },
@@ -0,0 +1,159 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { EarlyPlayerEmailRow } from "@/lib/card-gen/early-player-emails";
type Props = {
initialEmails: EarlyPlayerEmailRow[];
initialError: string | null;
onSelectId?: (id: number) => void;
};
const inputClass =
"w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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";
function utcStamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
export function EarlyEmailsList({
initialEmails,
initialError,
onSelectId,
}: Props) {
const [emails, setEmails] = useState(initialEmails);
const [error, setError] = useState(initialError);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/founders-card/signups", {
cache: "no-store",
});
const body = (await res.json().catch(() => null)) as
| { emails?: EarlyPlayerEmailRow[]; error?: string }
| null;
if (!res.ok) {
throw new Error(body?.error || `Could not load emails (${res.status})`);
}
setEmails(Array.isArray(body?.emails) ? body.emails : []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load emails");
} finally {
setLoading(false);
}
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return emails;
return emails.filter((row) => {
return (
String(row.id).includes(q) ||
row.email.toLowerCase().includes(q) ||
row.preset.toLowerCase().includes(q)
);
});
}, [emails, query]);
return (
<aside className="flex max-h-[min(80vh,900px)] min-h-[320px] flex-col rounded-xl border border-zinc-200 bg-white shadow-sm xl:sticky xl:top-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="shrink-0 space-y-3 border-b border-zinc-200 p-4 dark:border-zinc-800">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Early emails
</h2>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{emails.length.toLocaleString("en-US")} registered
</p>
</div>
<button
type="button"
onClick={() => void refresh()}
disabled={loading}
className="rounded-md border border-zinc-300 bg-white px-2.5 py-1 text-xs font-medium text-zinc-800 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{loading ? "…" : "Refresh"}
</button>
</div>
<input
className={inputClass}
placeholder="Search email, id, preset"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{error ? (
<p
className="m-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{error}
</p>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
{emails.length === 0 && !error ? (
<p className="px-4 py-8 text-center text-xs text-zinc-500 dark:text-zinc-400">
No early emails yet.
</p>
) : filtered.length === 0 ? (
<p className="px-4 py-8 text-center text-xs text-zinc-500 dark:text-zinc-400">
No emails match this search.
</p>
) : (
<ul className="divide-y divide-zinc-100 dark:divide-zinc-800">
{filtered.map((row) => {
const clickable = Boolean(onSelectId);
const inner = (
<>
<div className="flex items-baseline justify-between gap-2">
<span className="font-mono text-[11px] font-semibold tabular-nums text-zinc-500 dark:text-zinc-400">
#{row.id}
</span>
<time
dateTime={row.created_at}
className="shrink-0 text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500"
>
{utcStamp(row.created_at)}
</time>
</div>
<p className="mt-0.5 truncate text-xs font-medium text-zinc-900 dark:text-zinc-50">
{row.email}
</p>
<p className="mt-0.5 truncate text-[10px] text-zinc-500 dark:text-zinc-400">
{row.preset}
</p>
</>
);
return (
<li key={row.id}>
{clickable ? (
<button
type="button"
onClick={() => onSelectId?.(row.id)}
className="block w-full px-4 py-2.5 text-left hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
title="Preview this founder id on the card"
>
{inner}
</button>
) : (
<div className="px-4 py-2.5">{inner}</div>
)}
</li>
);
})}
</ul>
)}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { AdminHeader } from "@/components/admin-header";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { canWritePage, navPageAccess } from "@/lib/auth/permissions";
import { requirePageRead } from "@/lib/auth/require-session";
import { listEarlyPlayerEmails } from "@/lib/card-gen/early-player-emails";
import { FoundersCardEditor } from "./editor";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "Founders card design · Kick Kings Admin",
};
}
export default async function FoundersCardDesignPage() {
const account = await requirePageRead("founders-card");
await logPageAccess(account, "founders-card-design");
const canGenerate = canWritePage(account, "founders-card");
const emails = await listEarlyPlayerEmails();
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={navPageAccess(account)}
activeTab="founders-card"
/>
<FoundersCardEditor
canGenerate={canGenerate}
earlyEmails={"rows" in emails ? emails.rows : []}
earlyEmailsError={"error" in emails ? emails.error : null}
/>
</div>
);
}
+80
View File
@@ -24,3 +24,83 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Roboto";
src: url("/fonts/Roboto-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Roboto";
src: url("/fonts/Roboto-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Oswald";
src: url("/fonts/Oswald-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Oswald";
src: url("/fonts/Oswald-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Playfair Display";
src: url("/fonts/PlayfairDisplay-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Playfair Display";
src: url("/fonts/PlayfairDisplay-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
+11 -7
View File
@@ -1,10 +1,11 @@
import { Fragment } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AdminHeader } from "@/components/admin-header";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { navPageAccess } from "@/lib/auth/permissions";
import { requirePageRead } from "@/lib/auth/require-session";
import {
formatRcDecimalFromCoinsBigInt,
formatRcLabelFromCoinsBigInt,
@@ -58,10 +59,8 @@ export default async function LedgerBookPage({
}: {
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requirePageRead("ledger");
await logPageAccess(account, "ledger-book");
const sp = await searchParams;
let fromStr = firstSearchParam(sp.from);
@@ -151,7 +150,12 @@ export default async function LedgerBookPage({
return (
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
<AdminHeader />
<AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={navPageAccess(account)}
activeTab="ledger"
/>
<main className="flex-1 space-y-6 px-6 py-8">
<div className="mx-auto max-w-[1200px] space-y-2">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
+12 -10
View File
@@ -5,6 +5,7 @@ type Props = {
export default async function LoginPage({ searchParams }: Props) {
const { error } = await searchParams;
const invalid = error === "1";
const locked = error === "locked";
return (
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {
<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"
@@ -38,6 +29,9 @@ export default async function LoginPage({ searchParams }: Props) {
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
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
/>
@@ -54,10 +48,18 @@ export default async function LoginPage({ searchParams }: Props) {
name="password"
type="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
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>
{locked ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Too many failed attempts. Wait about 15 minutes, then try again.
</p>
) : null}
{invalid ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Invalid username or password.
+13 -6
View File
@@ -1,13 +1,15 @@
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 { notFound } from "next/navigation";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session";
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
import {
parseMatchIdParam,
readMatchLogFile,
} from "@/lib/match-logs-server";
import { redirect } from "next/navigation";
export async function generateMetadata({
params,
@@ -25,9 +27,12 @@ export default async function MatchLogPage({
}: {
params: Promise<{ matchId: string }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
const { matchId: raw } = await params;
@@ -36,6 +41,8 @@ export default async function MatchLogPage({
notFound();
}
await logPageAccess(account, "match-log", { matchId });
const result = await readMatchLogFile(matchId);
return (
+4 -7
View File
@@ -1,8 +1,7 @@
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 { logPageAccess } from "@/lib/auth/log-page-access";
import { requirePageRead } from "@/lib/auth/require-session";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
@@ -17,10 +16,8 @@ export default async function MatchmakerLogsPage({
}: {
searchParams: Promise<{ mklog?: string | string[] }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requirePageRead("matchmaker");
await logPageAccess(account, "matchmaker-logs");
const sp = await searchParams;
const raw =
+217 -18
View File
@@ -1,11 +1,24 @@
import { AdminDashboard } from "@/components/admin-dashboard";
import { AdminHeader } from "@/components/admin-header";
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
import {
canReadPage,
canWritePage,
navPageAccess,
tabToPageKey,
} from "@/lib/auth/permissions";
import { requireSession } from "@/lib/auth/require-session";
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
import {
normalizeLedgerDateRange,
parseIsoDateOnly,
utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range";
import {
localDateRangeToUtcIsoBounds,
normalizeMatchesDateRange,
parseTzOffsetMinutes,
} from "@/lib/local-date-range";
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
import {
serializeLedgerGlobalSummary,
@@ -18,7 +31,22 @@ import {
type MatchmakerLogSource,
} from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import { fetchEntryHoldPerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import { replayFileExists } from "@/lib/match-replays-server";
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import {
analyzeMatchLogsForMatches,
parseAnalysisPlayerIds,
} from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
loadGeolocationAnalytics,
type GeolocationAnalyticsResult,
} from "@/lib/geolocation-analytics";
import {
loadPingAnalytics,
type PingAnalyticsResult,
} from "@/lib/ping-analytics";
import type {
AdminMatchRow,
DbMatch,
@@ -35,6 +63,9 @@ import {
type LedgerSortKey,
type LedgerSortOrder,
} from "@/lib/ledger-table-view";
import { redirect } from "next/navigation";
import { readAuditLog, type AuditLogEntry } from "@/lib/auth/audit-log";
import { logPageAccess } from "@/lib/auth/log-page-access";
export const dynamic = "force-dynamic";
@@ -55,6 +86,7 @@ export default async function Home({
participant?: string | string[];
edit?: string | string[];
saveError?: string | string[];
supplyErr?: string | string[];
mklog?: string | string[];
lfrom?: string | string[];
lto?: string | string[];
@@ -62,11 +94,22 @@ export default async function Home({
lsize?: string | string[];
lsort?: string | string[];
lorder?: string | string[];
afrom?: string | string[];
ato?: string | string[];
aplayers?: string | string[];
mfrom?: string | string[];
mto?: string | string[];
mtz?: string | string[];
}>;
}) {
const account = await requireSession();
const canWritePlayers = canWritePage(account, "players");
const canWriteLedger = canWritePage(account, "ledger");
const pageAccess = navPageAccess(account);
const sp = await searchParams;
const tabParam = firstSearchParam(sp.tab);
const tab: AdminDashboardTab =
const requestedTab: AdminDashboardTab =
tabParam === "matches"
? "matches"
: tabParam === "players"
@@ -75,11 +118,27 @@ export default async function Home({
? "matchmaker"
: tabParam === "ledger"
? "ledger"
: "dashboard";
: tabParam === "analysis"
? "analysis"
: tabParam === "logs"
? "logs"
: "dashboard";
const pageKey = tabToPageKey(requestedTab);
if (pageKey && !canReadPage(account, pageKey)) {
redirect("/");
}
const tab = requestedTab;
const accessPage =
tab === "dashboard"
? "overview"
: tab;
await logPageAccess(account, accessPage);
const highlightId = firstSearchParam(sp.highlight);
const participantRaw = firstSearchParam(sp.participant);
const editRaw = firstSearchParam(sp.edit);
const saveError = firstSearchParam(sp.saveError) === "1";
const supplyError = firstSearchParam(sp.supplyErr) === "1";
const editIdNum =
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
@@ -101,12 +160,44 @@ export default async function Home({
let ledgerSortOrderView: LedgerSortOrder = "desc";
let ledgerFrom = utcLast30DaysDateRange().from;
let ledgerTo = utcLast30DaysDateRange().to;
let analysisFrom = utcLast30DaysDateRange().from;
let analysisTo = utcLast30DaysDateRange().to;
let matchesFrom = utcLast30DaysDateRange().from;
let matchesTo = utcLast30DaysDateRange().to;
let matchesTzOffsetMinutes: number | null = null;
let matchesRangeExplicit = false;
let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let pingAnalytics: PingAnalyticsResult = {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: 200,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: null,
};
let geolocationAnalytics: GeolocationAnalyticsResult = {
userAccounts: [],
matches: [],
error: null,
};
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null;
let matchmakerSource: MatchmakerLogSource = "processed";
let matchmakerContent = "";
let matchmakerError: string | null = null;
let auditEntries: AuditLogEntry[] = [];
let auditError: string | null = null;
let matchIdsWithReplay: number[] = [];
if (tab === "matchmaker") {
const mkRaw = firstSearchParam(sp.mklog);
matchmakerSource = parseMatchmakerLogParam(mkRaw);
@@ -118,13 +209,22 @@ export default async function Home({
}
}
if (tab === "logs") {
try {
auditEntries = await readAuditLog(1000);
} catch (err) {
auditError =
err instanceof Error ? err.message : "Failed to read system logs.";
}
}
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")
.select("id, created_at, username, email, ip_address, cc, rc, mmr, last_logged_at")
.order("id", { ascending: false })
.limit(500);
@@ -134,37 +234,85 @@ export default async function Home({
users = (usersRes.data ?? []) as DbUser[];
}
const matchesRes = await supabase
const matchesTzRaw = firstSearchParam(sp.mtz);
matchesTzOffsetMinutes = parseTzOffsetMinutes(matchesTzRaw);
const mfromRaw = firstSearchParam(sp.mfrom);
const mtoRaw = firstSearchParam(sp.mto);
matchesRangeExplicit = Boolean(
mfromRaw &&
mtoRaw &&
parseIsoDateOnly(mfromRaw.trim()) &&
parseIsoDateOnly(mtoRaw.trim()),
);
const matchesRange = normalizeMatchesDateRange(
mfromRaw,
mtoRaw,
matchesTzOffsetMinutes,
);
matchesFrom = matchesRange.from;
matchesTo = matchesRange.to;
let matchesQuery = supabase
.from("matches")
.select("*")
.order("id", { ascending: false })
.limit(500);
.order("id", { ascending: false });
if (tab === "matches") {
const tz = matchesTzOffsetMinutes ?? 0;
const { rangeStartIso, rangeEndIso } = localDateRangeToUtcIsoBounds(
matchesFrom,
matchesTo,
tz,
);
matchesQuery = matchesQuery
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.limit(10000);
} else {
matchesQuery = matchesQuery.limit(500);
}
const matchesRes = await matchesQuery;
if (matchesRes.error) {
matchesError = matchesRes.error.message;
} else {
const rawMatches = (matchesRes.data ?? []) as DbMatch[];
const holdByMatch = await fetchEntryHoldPerPlayerCoinsByMatchIds(
supabase,
rawMatches
.map((m) => Number(m.id))
.filter((id) => Number.isFinite(id)),
);
const { holdPerPlayer, feePerPlayer } =
await fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
supabase,
rawMatches
.map((m) => Number(m.id))
.filter((id) => Number.isFinite(id)),
);
matches = rawMatches.map((m) => {
const idNum = Number(m.id);
const hold = Number.isFinite(idNum)
? holdByMatch.get(idNum)
: undefined;
const hold =
Number.isFinite(idNum) ? holdPerPlayer.get(idNum) : undefined;
const fee =
Number.isFinite(idNum) ? feePerPlayer.get(idNum) : undefined;
return {
...m,
entryHoldPerPlayerCoins:
hold !== undefined ? hold.toString() : null,
entryFeePerPlayerCoins:
fee !== undefined ? fee.toString() : null,
};
});
}
statsBundle = await loadDashboardStatsBundle(supabase);
if (tab === "matches" && pageAccess.matches) {
const checks = await Promise.all(
matches.map(async (m) => {
const id = Number(m.id);
return (await replayFileExists(id)) ? id : null;
}),
);
matchIdsWithReplay = checks.filter((id): id is number => id !== null);
}
if (tab === "ledger") {
const lfromRaw = firstSearchParam(sp.lfrom);
const ltoRaw = firstSearchParam(sp.lto);
@@ -223,16 +371,51 @@ export default async function Home({
ledgerTotalPagesView = slice.totalPages;
}
}
if (tab === "analysis") {
const afromRaw = firstSearchParam(sp.afrom);
const atoRaw = firstSearchParam(sp.ato);
const range = normalizeLedgerDateRange(afromRaw, atoRaw);
analysisFrom = range.from;
analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
const [matchLogResult, pingResult, geoResult] = await Promise.all([
analyzeMatchLogsForMatches(
matches,
analysisFrom,
analysisTo,
analysisPlayerIds,
),
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
loadGeolocationAnalytics(
supabase,
users,
analysisFrom,
analysisTo,
analysisPlayerIds,
),
]);
matchAnalysis = matchLogResult;
pingAnalytics = pingResult;
geolocationAnalytics = geoResult;
}
}
let editUser: DbUser | null = null;
if (Number.isInteger(editIdNum) && editIdNum >= 1) {
if (canWritePlayers && 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 />
<AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={pageAccess}
activeTab={tab === "dashboard" ? "overview" : tab}
playersLabel={`Players (${(statsBundle?.stats?.totalUsers ?? users.length).toLocaleString("en-US")})`}
matchesLabel={`Matches (${(statsBundle?.stats?.totalMatches ?? matches.length).toLocaleString("en-US")})`}
/>
{configError ? (
<div className="px-6 pt-8">
<div
@@ -268,6 +451,22 @@ export default async function Home({
ledgerOrder={ledgerSortOrderView}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
matchesFrom={matchesFrom}
matchesTo={matchesTo}
matchesTzOffsetMinutes={matchesTzOffsetMinutes}
matchesRangeExplicit={matchesRangeExplicit}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
auditEntries={auditEntries}
auditError={auditError}
canWritePlayers={canWritePlayers}
canWriteLedger={canWriteLedger}
matchIdsWithReplay={matchIdsWithReplay}
/>
{editUser ? (
<EditUserCcRcOverlay
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ReplayViewer } from "@/components/replay-viewer";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session";
import { parseMatchIdParam } from "@/lib/match-logs-server";
import { readMatchReplayFile } from "@/lib/match-replays-server";
export async function generateMetadata({
params,
}: {
params: Promise<{ matchId: string }>;
}): Promise<Metadata> {
const { matchId } = await params;
return {
title: `Match ${matchId} replay · Kick Kings Admin`,
};
}
export default async function MatchReplayPage({
params,
}: {
params: Promise<{ matchId: string }>;
}) {
const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
const { matchId: raw } = await params;
const matchId = parseMatchIdParam(raw);
if (matchId == null) {
notFound();
}
await logPageAccess(account, "match-replay", { matchId });
const result = await readMatchReplayFile(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}.json</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="flex min-h-0 flex-1 flex-col p-4">
{!result.ok ? (
<p className="font-mono text-sm text-red-400">
<span className="text-red-500/80">error:</span> {result.message}
</p>
) : (
<ReplayViewer
initialReplay={result.replay}
initialFileName={result.fileName}
/>
)}
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ReplayViewer } from "@/components/replay-viewer";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session";
export const metadata: Metadata = {
title: "Replay viewer · Kick Kings Admin",
};
export default async function ReplaysPage() {
const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
await logPageAccess(account, "replay-viewer");
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">replay viewer</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="flex min-h-0 flex-1 flex-col p-4">
<ReplayViewer />
</div>
</div>
);
}
+38 -11
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth/require-session";
import { listSessionAccounts } from "@/lib/auth/accounts-store";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { navPageAccess } from "@/lib/auth/permissions";
import { AdminHeader } from "@/components/admin-header";
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
import { createAdminSupabase } from "@/lib/supabase/admin";
import type { DbSetting } from "@/types/database";
@@ -29,16 +31,20 @@ export default async function SettingsPage({
searchParams: Promise<{
saveError?: string | string[];
addError?: string | string[];
accountError?: string | string[];
accountOk?: string | string[];
}>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requireAdmin();
await logPageAccess(account, "settings");
const sp = await searchParams;
const saveError = firstSearchParam(sp.saveError) === "1";
const addError = firstSearchParam(sp.addError);
const accountError = firstSearchParam(sp.accountError);
const accountOk = firstSearchParam(sp.accountOk);
const accounts = await listSessionAccounts();
const supabase = createAdminSupabase();
let rows: DbSetting[] = [];
@@ -63,12 +69,32 @@ export default async function SettingsPage({
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]">
<AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={navPageAccess(account)}
activeTab="settings"
/>
<main className="flex-1 space-y-12 px-6 py-8">
<div className="mx-auto max-w-[900px]">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings
Settings
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Global game settings and panel account access. Admin only.
</p>
</div>
<AdminAccountsEditor
accounts={accounts}
accountError={accountError}
accountOk={accountOk}
/>
<div className="mx-auto max-w-[900px] border-t border-zinc-200 pt-10 dark:border-zinc-800">
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings
</h2>
<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">
@@ -96,6 +122,7 @@ export default async function SettingsPage({
rows={rows}
saveError={saveError}
addError={addError}
readOnly={false}
/>
)}
</main>
+431
View File
@@ -0,0 +1,431 @@
"use client";
import { useState } from "react";
import {
createAdminAccount,
deleteAdminAccount,
updateAdminAccount,
} from "@/app/actions/account-actions";
import {
emptyPagePermissions,
PAGE_KEYS,
type PageKey,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const PAGE_LABELS: Record<PageKey, string> = {
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
"founders-card": "Founders card",
};
type Props = {
accounts: SessionAccount[];
accountError: string | null;
accountOk: string | null;
};
function errorMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "missingUsername":
return "Username is required.";
case "missingPassword":
return "Password is required.";
case "duplicate":
return "That username is already taken.";
case "notFound":
return "Account not found.";
case "cannotDeleteAdmin":
return "The admin account cannot be deleted.";
default:
return "Could not update accounts. Try again.";
}
}
function okMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "created":
return "Account created.";
case "updated":
return "Account updated.";
case "deleted":
return "Account deleted.";
default:
return "Done.";
}
}
function PermissionCheckboxes({
idPrefix,
permissions,
onChange,
}: {
idPrefix: string;
permissions: PagePermissions;
onChange: (next: PagePermissions) => void;
}) {
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
const next = { ...permissions, [page]: { ...permissions[page] } };
if (page === "logs") {
next[page] = { read: field === "read" ? value : next[page].read, write: false };
onChange(next);
return;
}
if (field === "write") {
next[page] = { write: value, read: value ? true : next[page].read };
} else {
next[page] = {
read: value,
write: value ? next[page].write : false,
};
}
onChange(next);
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[320px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
<th className="py-2 pr-3 font-medium">Page</th>
<th className="py-2 px-2 font-medium">Read</th>
<th className="py-2 px-2 font-medium">Write</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
Overview
</td>
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
Always enabled
</td>
</tr>
{PAGE_KEYS.map((page) => (
<tr
key={page}
className="border-b border-zinc-100 dark:border-zinc-800"
>
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
{PAGE_LABELS[page]}
</td>
<td className="py-2 px-2">
<input
type="checkbox"
id={`${idPrefix}_${page}_read`}
name={`perm_${page}_read`}
value="1"
checked={permissions[page].read}
onChange={(e) => setPerm(page, "read", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
</td>
<td className="py-2 px-2">
{page === "logs" ? (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
</span>
) : (
<input
type="checkbox"
id={`${idPrefix}_${page}_write`}
name={`perm_${page}_write`}
value="1"
checked={permissions[page].write}
onChange={(e) => setPerm(page, "write", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function permissionSummary(account: SessionAccount): string {
if (account.isAdmin) return "Full access";
const parts: string[] = ["Overview"];
for (const page of PAGE_KEYS) {
const p = account.permissions[page];
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
}
return parts.join(", ");
}
function AccountEditForm({
account,
onClose,
}: {
account: SessionAccount;
onClose: () => void;
}) {
const [username, setUsername] = useState(account.username);
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState(account.permissions);
return (
<form
action={updateAdminAccount}
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
>
<input type="hidden" name="id" value={account.id} />
<input
type="hidden"
name="isAdmin"
value={account.isAdmin ? "1" : "0"}
/>
{account.isAdmin ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Admin account username is fixed; only the password can be changed.
</p>
) : (
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-username-${account.id}`}
>
Username
</label>
<input
id={`edit-username-${account.id}`}
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
)}
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-password-${account.id}`}
>
{account.isAdmin ? "New password" : "New password (optional)"}
</label>
<input
id={`edit-password-${account.id}`}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={account.isAdmin}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
{!account.isAdmin ? (
<PermissionCheckboxes
idPrefix={`edit_${account.id}`}
permissions={permissions}
onChange={setPermissions}
/>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
</div>
</form>
);
}
function AddAccountForm() {
const [permissions, setPermissions] = useState<PagePermissions>(() =>
emptyPagePermissions(),
);
return (
<form
action={createAdminAccount}
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Add account
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-username"
>
Username
</label>
<input
id="new-account-username"
name="username"
required
autoComplete="off"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm 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"
/>
</div>
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-password"
>
Password
</label>
<input
id="new-account-password"
name="password"
type="password"
required
autoComplete="new-password"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm 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"
/>
</div>
</div>
<PermissionCheckboxes
idPrefix="new"
permissions={permissions}
onChange={setPermissions}
/>
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Create account
</button>
</form>
);
}
export function AdminAccountsEditor({
accounts,
accountError,
accountOk,
}: Props) {
const [editingId, setEditingId] = useState<string | null>(null);
const err = errorMessage(accountError);
const ok = okMessage(accountOk);
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Accounts
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Manage panel logins and per-page read/write access. Only the admin
account can manage other accounts.
</p>
</div>
{err ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{err}
</div>
) : null}
{ok ? (
<div
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
role="status"
>
{ok}
</div>
) : null}
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing accounts
</h3>
<ul className="space-y-3">
{accounts.map((account) => (
<li
key={account.id}
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-wrap items-start justify-between gap-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">
{account.username}
{account.isAdmin ? (
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Admin
</span>
) : null}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{permissionSummary(account)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
setEditingId((id) =>
id === account.id ? null : account.id,
)
}
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{editingId === account.id ? "Close" : "Edit"}
</button>
{!account.isAdmin ? (
<form action={deleteAdminAccount}>
<input type="hidden" name="id" value={account.id} />
<button
type="submit"
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (
!window.confirm(
`Delete account “${account.username}”?`,
)
) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
) : null}
</div>
</div>
{editingId === account.id ? (
<AccountEditForm
account={account}
onClose={() => setEditingId(null)}
/>
) : null}
</li>
))}
</ul>
</section>
<AddAccountForm />
</div>
);
}
+311 -110
View File
@@ -4,14 +4,24 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AdminLedger } from "@/components/admin-ledger";
import { AdminSystemLogs } from "@/components/admin-system-logs";
import { LocalTimestamp } from "@/components/local-timestamp";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import {
localLast30DaysDateRange,
} from "@/lib/local-date-range";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type {
AdminMatchRow,
@@ -66,11 +76,6 @@ function formatTs(value: string | null): string {
}
}
function matchEntryFeeCoinString(v: DbMatch["entry_fee"]): string | null {
if (v == null) return null;
return txAmountToBigInt(v).toString();
}
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
if (v == null) return "—";
const b = txAmountToBigInt(v);
@@ -81,7 +86,7 @@ function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
}
}
type LeaderboardSortKey = "rank" | "player" | "rc" | "winRate" | "wins";
type LeaderboardSortKey = "rank" | "player" | "rc" | "mmr" | "winRate" | "wins";
function playerSortKey(row: LeaderboardRow): string {
const t = row.username?.trim();
@@ -89,6 +94,10 @@ function playerSortKey(row: LeaderboardRow): string {
return "\uffff";
}
function isEscrowUsername(username: string | null): boolean {
return (username ?? "").toLowerCase().startsWith("match_escrow_");
}
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
if (key === "player" || key === "rank") return "asc";
return "desc";
@@ -125,6 +134,15 @@ function sortLeaderboardRows(
else cmp = a.rcBalance! - b.rcBalance!;
break;
}
case "mmr": {
const aOk = a.mmr != null && Number.isFinite(a.mmr);
const bOk = b.mmr != null && Number.isFinite(b.mmr);
if (!aOk && !bOk) cmp = 0;
else if (!aOk) cmp = 1;
else if (!bOk) cmp = -1;
else cmp = a.mmr! - b.mmr!;
break;
}
case "winRate": {
const aOk =
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
@@ -220,6 +238,26 @@ type Props = {
matchmakerSource: MatchmakerLogSource;
matchmakerContent: string;
matchmakerError: string | null;
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
supplyError: boolean;
/** Matches tab: local date-only bounds + browser tz offset minutes. */
matchesFrom: string;
matchesTo: string;
matchesTzOffsetMinutes: number | null;
/** True when `mfrom`/`mto` were present and valid in the URL. */
matchesRangeExplicit: boolean;
analysisFrom: string;
analysisTo: string;
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
auditEntries: AuditLogEntry[];
auditError: string | null;
canWritePlayers: boolean;
canWriteLedger: boolean;
/** Match IDs that have a replay JSON file on disk. */
matchIdsWithReplay: number[];
};
function StatCard({
@@ -269,11 +307,60 @@ export function AdminDashboard({
matchmakerSource,
matchmakerContent,
matchmakerError,
supplyError,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
matchesRangeExplicit,
analysisFrom,
analysisTo,
analysisPlayerIds,
matchAnalysis,
pingAnalytics,
geolocationAnalytics,
auditEntries,
auditError,
canWritePlayers,
canWriteLedger,
matchIdsWithReplay,
}: Props) {
const replaySet = useMemo(
() => new Set(matchIdsWithReplay),
[matchIdsWithReplay],
);
const [hideNoWinner, setHideNoWinner] = useState(true);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [playersSearch, setPlayersSearch] = useState("");
const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("mmr");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
/** Once we know the browser offset, reload with mtz so day bounds are local. */
useEffect(() => {
if (tab !== "matches") return;
if (matchesTzOffsetMinutes != null) return;
const tz = new Date().getTimezoneOffset();
const range = matchesRangeExplicit
? { from: matchesFrom, to: matchesTo }
: localLast30DaysDateRange(tz);
const href = buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom: range.from,
matchesTo: range.to,
matchesTzOffsetMinutes: tz,
});
window.location.replace(href);
}, [
tab,
matchesTzOffsetMinutes,
matchesRangeExplicit,
highlightId,
participantRaw,
matchesFrom,
matchesTo,
]);
const lbRaw = statsBundle?.leaderboard;
const sortedLeaderboard = useMemo(() => {
const rows = lbRaw ?? [];
@@ -322,6 +409,30 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
const playersPool = useMemo(() => {
if (showEscrowAccounts) return users;
return users.filter((u) => !isEscrowUsername(u.username));
}, [users, showEscrowAccounts]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return playersPool;
return playersPool.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
u.mmr == null ? "" : String(u.mmr),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [playersPool, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -329,14 +440,6 @@ export function AdminDashboard({
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,
@@ -346,10 +449,6 @@ export function AdminDashboard({
});
}
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";
@@ -359,81 +458,6 @@ export function AdminDashboard({
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={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</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">
@@ -521,6 +545,7 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<th className="px-4 py-3 font-medium">Email</th>
<LeaderboardSortTh
label="RC balance"
sortKey="rc"
@@ -528,6 +553,14 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<LeaderboardSortTh
label="MMR"
sortKey="mmr"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
align="right"
/>
<LeaderboardSortTh
label="Win rate"
sortKey="winRate"
@@ -550,7 +583,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={5}
colSpan={7}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
@@ -578,9 +611,17 @@ export function AdminDashboard({
)}
</span>
</td>
<td className="max-w-48 truncate px-4 py-2 text-zinc-700 dark:text-zinc-300">
{row.email?.trim() ? row.email.trim() : "—"}
</td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)}
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.mmr == null
? "—"
: row.mmr.toLocaleString("en-US")}
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.winRatePercent == null
? "—"
@@ -605,31 +646,67 @@ export function AdminDashboard({
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label className="min-w-[16rem] flex-1">
<span className="sr-only">Search players</span>
<input
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, MMR, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<label
htmlFor="admin-show-escrow-accounts"
className="inline-flex cursor-pointer items-center gap-2 text-sm whitespace-nowrap text-zinc-700 dark:text-zinc-200"
>
<input
id="admin-show-escrow-accounts"
type="checkbox"
checked={showEscrowAccounts}
onChange={(e) => setShowEscrowAccounts(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"
/>
Show escrow accounts
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {playersPool.length}
</span>
</div>
<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">Email</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">MMR</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">IP</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 ? (
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={7}
colSpan={10}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
{playersPool.length === 0
? users.length === 0
? "No players yet."
: "No players left after hiding escrow accounts."
: "No players match this search."}
</td>
</tr>
) : (
users.map((u) => {
filteredUsers.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
@@ -647,23 +724,32 @@ export function AdminDashboard({
<ClickableUserId id={u.id} />
</td>
<td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="max-w-48 truncate px-4 py-2">
{u.email?.trim() ? u.email.trim() : "—"}
</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">{u.mmr ?? "—"}</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 font-mono text-xs whitespace-nowrap">
{u.ip_address?.trim() ? u.ip_address.trim() : "—"}
</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>
{canWritePlayers ? (
<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>
) : null}
<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"
@@ -680,6 +766,7 @@ export function AdminDashboard({
</tbody>
</table>
</div>
</div>
)}
</section>
) : tab === "matchmaker" ? (
@@ -723,6 +810,18 @@ export function AdminDashboard({
errorMessage={matchmakerError}
/>
</section>
) : tab === "analysis" ? (
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
selectedPlayerIds={analysisPlayerIds}
highlightId={highlightId}
participantRaw={participantRaw}
/>
) : tab === "ledger" ? (
<AdminLedger
ledgerGlobalSummary={ledgerGlobalSummary}
@@ -740,9 +839,103 @@ export function AdminDashboard({
participantRaw={participantRaw}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
readOnly={!canWriteLedger}
/>
) : tab === "logs" ? (
<AdminSystemLogs entries={auditEntries} error={auditError} />
) : (
<section className="space-y-3">
<form
method="get"
action="/"
className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
const form = e.currentTarget;
const mtzInput = form.elements.namedItem(
"mtz",
) as HTMLInputElement | null;
if (mtzInput) {
mtzInput.value = String(new Date().getTimezoneOffset());
}
}}
>
<input type="hidden" name="tab" value="matches" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<input
type="hidden"
name="mtz"
defaultValue={
matchesTzOffsetMinutes != null
? String(matchesTzOffsetMinutes)
: ""
}
/>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mfrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (local)
</label>
<input
id="matches-mfrom"
name="mfrom"
type="date"
defaultValue={matchesFrom}
className="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 min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mto"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (local)
</label>
<input
id="matches-mto"
name="mto"
type="date"
defaultValue={matchesTo}
className="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="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesTzOffsetMinutes,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset range
</Link>
</form>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {matches.length.toLocaleString("en-US")}{" "}
{matches.length === 1 ? "match" : "matches"} created{" "}
<span className="font-mono">
{matchesFrom}{matchesTo}
</span>{" "}
in your local timezone
{matches.length >= 10000 ? " · capped at 10,000 rows" : null}
</p>
{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"
@@ -764,7 +957,14 @@ export function AdminDashboard({
{filteredMatches.length} of {matches.length})
</span>
<Link
href="/?tab=matches"
href={buildDashboardHref({
tab: "matches",
highlightId: null,
participantRaw: null,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
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}
>
@@ -800,7 +1000,7 @@ export function AdminDashboard({
{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."
? "No matches in this date range."
: hideNoWinner
? "No matches left after hiding no-winner matches."
: "No matches for this filter."}
@@ -818,11 +1018,12 @@ export function AdminDashboard({
key={m.id}
matchId={m.id}
statusLabel={statusLabel(m.status)}
createdAtLabel={formatTs(m.created_at)}
entryFeeCoins={matchEntryFeeCoinString(m.entry_fee)}
createdAtLabel={<LocalTimestamp value={m.created_at} />}
entryFeeCoins={m.entryFeePerPlayerCoins}
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
winnerId={m.winner_id}
hasReplay={replaySet.has(Number(m.id))}
left={{
id: redId,
username: leftUser?.username ?? null,
+55 -13
View File
@@ -1,25 +1,67 @@
"use client";
export function AdminHeader() {
import {
AdminNavTabs,
type AdminNavTab,
} from "@/components/admin-nav-tabs";
import type { NavPageAccess } from "@/lib/auth/permissions";
type Props = {
username?: string;
isAdmin?: boolean;
pageAccess?: NavPageAccess;
activeTab?: AdminNavTab;
playersLabel?: string;
matchesLabel?: string;
};
export function AdminHeader({
username,
isAdmin = false,
pageAccess,
activeTab = "overview",
playersLabel,
matchesLabel,
}: Props) {
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>
<header className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<div>
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard
</h1>
{username ? (
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
Signed in as{" "}
<span className="font-medium text-zinc-700 dark:text-zinc-300">
{username}
</span>
{isAdmin ? " · admin" : null}
</p>
) : null}
</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>
</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>
{pageAccess ? (
<AdminNavTabs
active={activeTab}
pageAccess={pageAccess}
isAdmin={isAdmin}
playersLabel={playersLabel}
matchesLabel={matchesLabel}
/>
) : null}
</header>
);
}
+171 -8
View File
@@ -1,10 +1,13 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
import { auditMatchEconomics } from "@/lib/ledger-match-audit";
import {
computeLedgerIntegrity,
@@ -21,6 +24,7 @@ import {
type LedgerSortOrder,
} from "@/lib/ledger-table-view";
import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series";
import { addSystemSupply } from "@/app/actions/add-system-supply";
import { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart";
import type { DbTransaction, DbUser } from "@/types/database";
@@ -86,6 +90,8 @@ type Props = {
participantRaw: string | null;
ledgerFrom: string;
ledgerTo: string;
supplyError: boolean;
readOnly: boolean;
};
export function AdminLedger({
@@ -104,6 +110,8 @@ export function AdminLedger({
participantRaw,
ledgerFrom,
ledgerTo,
supplyError,
readOnly,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
@@ -113,6 +121,13 @@ export function AdminLedger({
return m;
}, [users]);
const systemAccount = useMemo(() => {
for (const u of users) {
if (Number(u.id) === 1) return u;
}
return null;
}, [users]);
const { integrityGlobal, userNets } = useMemo(() => {
if (ledgerGlobalSummary == null) {
return {
@@ -240,6 +255,25 @@ export function AdminLedger({
);
const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages);
const [supplyModalOpen, setSupplyModalOpen] = useState(false);
useEffect(() => {
if (supplyError) {
setSupplyModalOpen(true);
}
}, [supplyError]);
useEffect(() => {
if (!supplyModalOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSupplyModalOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [supplyModalOpen]);
const navLinkClass =
"rounded px-2 py-1 font-medium text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/40";
const navMutedClass = "rounded px-2 py-1 text-zinc-400 dark:text-zinc-500";
@@ -275,12 +309,25 @@ export function AdminLedger({
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
].join(" ")}
>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
</div>
{!readOnly ? (
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
) : null}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrityGlobal.sumUserNets)}
@@ -956,6 +1003,122 @@ export function AdminLedger({
</div>
) : null}
{supplyModalOpen && !readOnly ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
onClick={() => setSupplyModalOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="supply-modal-title"
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"
onClick={(e) => e.stopPropagation()}
>
<h2
id="supply-modal-title"
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
>
Add system supply
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Credits user id{" "}
<span className="font-mono">1</span>
{systemAccount?.username ? (
<span>
{" "}
(<span className="font-mono">{systemAccount.username}</span>)
</span>
) : null}
: mint row (<span className="font-mono">to = 1</span>,{" "}
<span className="font-mono">remarks = supply</span>), RC increases
by the same coin amount.
</p>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
Current RC:{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{systemAccount
? formatRcBalanceWithCoins(systemAccount.rc)
: "— (user 1 not in loaded set)"}
</span>
</p>
{supplyError ? (
<p
className="mt-3 text-sm text-red-600 dark:text-red-400"
role="alert"
>
Could not add supply. Use a positive whole number of coin units
(no decimals), and ensure the database and service key are
available.
</p>
) : null}
<form
action={addSystemSupply}
className="mt-5 space-y-4"
>
<input type="hidden" name="tab" value="ledger" />
<input
type="hidden"
name="highlightId"
value={highlightId ?? ""}
/>
<input
type="hidden"
name="participantRaw"
value={participantRaw ?? ""}
/>
<input type="hidden" name="ledgerFrom" value={ledgerFrom} />
<input type="hidden" name="ledgerTo" value={ledgerTo} />
<input
type="hidden"
name="ledgerPage"
value={String(ledgerPage)}
/>
<input
type="hidden"
name="ledgerPageSize"
value={String(ledgerPageSize)}
/>
<input type="hidden" name="ledgerSort" value={ledgerSort} />
<input type="hidden" name="ledgerOrder" value={ledgerOrder} />
<div>
<label
htmlFor="supply-amount-modal"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Coin amount
</label>
<input
id="supply-amount-modal"
name="supplyAmount"
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="e.g. 4000"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm tabular-nums 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 gap-2 pt-2">
<button
type="button"
onClick={() => setSupplyModalOpen(false)}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
type="submit"
className="rounded-md border border-emerald-600 bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 dark:border-emerald-700 dark:bg-emerald-800 dark:hover:bg-emerald-700"
>
Submit
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
+630
View File
@@ -0,0 +1,630 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { AnalysisGeolocationMap } from "@/components/analysis-geolocation-map";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database";
function formatDuration(seconds: number): string {
const s = Math.round(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function formatAggregate(
agg: NumericAggregate | null,
formatValue: (n: number) => string,
): { avg: string; min: string; max: string } {
if (!agg) {
return { avg: "—", min: "—", max: "—" };
}
return {
avg: formatValue(agg.avg),
min: formatValue(agg.min),
max: formatValue(agg.max),
};
}
function StatBlock({
title,
avg,
min,
max,
hint,
}: {
title: string;
avg: string;
min: string;
max: string;
hint?: string;
}) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{title}
</p>
{hint ? (
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
{hint}
</p>
) : null}
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<div>
<dt className="text-[10px] uppercase text-zinc-500">Avg</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{avg}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Min</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{min}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Max</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{max}
</dd>
</div>
</dl>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
type Props = {
analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
selectedPlayerIds: number[];
highlightId: string | null;
participantRaw: string | null;
};
export function AdminMatchAnalysis({
analysis,
pingAnalytics,
geolocationAnalytics,
users,
analysisFrom,
analysisTo,
selectedPlayerIds,
highlightId,
participantRaw,
}: Props) {
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 durationFmt = formatAggregate(analysis.duration, formatDuration);
const shotsFmt = formatAggregate(analysis.shots, (n) =>
Math.round(n).toLocaleString("en-US"),
);
const forceFmt = formatAggregate(analysis.force, (n) =>
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
);
const winnerRatioPct =
analysis.matchesWithLogFiles > 0
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
const fmtMs = (value: number | null): string =>
value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`;
const fmtPct = (value: number | null): string =>
value == null
? "—"
: `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
const reports = pingAnalytics.summary.totalReports;
const avgPing = pingAnalytics.summary.avgPing;
const p95Ping = pingAnalytics.summary.p95Ping;
const badThreshold = pingAnalytics.summary.badThresholdMs;
const badReports = pingAnalytics.summary.badReports;
const badRate = pingAnalytics.summary.badRatePercent;
const goodCount =
reports > 0 && avgPing != null
? Math.max(0, reports - badReports)
: Math.max(0, reports - badReports);
const topCountryRows = pingAnalytics.byCountries.slice(0, 8);
const [pingView, setPingView] = useState<"matches" | "players" | "countries">(
"matches",
);
const pingRows =
pingView === "matches"
? pingAnalytics.byMatches
: pingView === "players"
? pingAnalytics.byPlayers
: pingAnalytics.byCountries;
return (
<section className="space-y-6">
{analysis.configError ? (
<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"
>
{analysis.configError}
</div>
) : null}
<form
method="get"
action="/"
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="tab" value="analysis" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-afrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (UTC)
</label>
<input
id="analysis-afrom"
name="afrom"
type="date"
defaultValue={analysisFrom}
className="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 min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-ato"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (UTC)
</label>
<input
id="analysis-ato"
name="ato"
type="date"
defaultValue={analysisTo}
className="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>
<fieldset>
<legend className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
Players (optional leave empty for all)
</legend>
<div className="mt-2 max-h-48 overflow-y-auto rounded-md border border-zinc-200 p-2 dark:border-zinc-700">
<div className="flex flex-wrap gap-x-4 gap-y-2">
{users.length === 0 ? (
<p className="text-sm text-zinc-500">No players loaded.</p>
) : (
users.map((u) => {
const checked = selectedPlayerIds.includes(u.id);
return (
<label
key={u.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
name="aplayers"
value={String(u.id)}
defaultChecked={checked}
className="rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600"
/>
<span className="font-mono text-xs">
<ClickableUserId id={u.id} />
{u.username ? (
<span className="text-zinc-600 dark:text-zinc-400">
{" "}
({u.username})
</span>
) : null}
</span>
</label>
);
})
)}
</div>
</div>
</fieldset>
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset filters
</Link>
</div>
</form>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300">
<span className="font-mono">
{analysisFrom}{analysisTo}
</span>{" "}
UTC
{selectedPlayerIds.length > 0 ? (
<>
{" "}
· players{" "}
{selectedPlayerIds.map((id) => (
<span key={id} className="font-mono">
{id}
{usernameById.get(id) ? ` (${usernameById.get(id)})` : ""}
{" "}
</span>
))}
</>
) : (
" · all players"
)}
<br />
<span className="text-zinc-600 dark:text-zinc-400">
{analysis.matchesInFilter.toLocaleString("en-US")} matches in filter
{analysis.matchesScanned < analysis.matchesInFilter
? ` · scanned ${analysis.matchesScanned.toLocaleString("en-US")} (cap)`
: null}
· {analysis.matchesWithLogs.toLocaleString("en-US")} with parsed logs
{analysis.matchesMissingLogs > 0
? ` · ${analysis.matchesMissingLogs.toLocaleString("en-US")} missing or empty`
: null}
{analysis.matchesSkippedTooLarge > 0
? ` · ${analysis.matchesSkippedTooLarge.toLocaleString("en-US")} too large`
: null}
</span>
</div>
<AnalysisMatchAlertBox
category="mirror"
matchIds={analysis.mirrorExceptionDisconnectMatchIds}
title="Mirror exception disconnect"
description={
<>
These matches include a{" "}
<span className="font-mono">[Mirror/Error]</span> line where a
player was disconnected because handling a command caused an
exception.
</>
}
/>
<div className="grid gap-4 lg:grid-cols-3">
<StatBlock
title="Match length"
avg={durationFmt.avg}
min={durationFmt.min}
max={durationFmt.max}
hint="Ended matches only · first to last log line (m:ss)"
/>
<StatBlock
title="Shots per match"
avg={shotsFmt.avg}
min={shotsFmt.min}
max={shotsFmt.max}
hint="Ended matches only · launching puck lines"
/>
<StatBlock
title="Shot force magnitude"
avg={forceFmt.avg}
min={forceFmt.min}
max={forceFmt.max}
hint="√(fx² + fy²) per shot"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Force distribution (Fx, Fy)
</h3>
<MatchAnalysisForceChart points={analysis.forcePoints} />
</div>
<div className="space-y-6">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Text emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.textEmotes}
title="Text emotes"
barClassName="fill-violet-500 dark:fill-violet-400"
/>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Emoji emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.emojiEmotes}
title="Emoji emotes"
barClassName="fill-amber-500 dark:fill-amber-400"
labelWidth={72}
labelMaxLen={12}
labelMonospace={false}
/>
</div>
</div>
</div>
{analysis.matchesWithLogFiles > 0 ? (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Match completion (from logs)
</h3>
<p className="mt-2 text-sm text-zinc-700 dark:text-zinc-300">
Winner logged:{" "}
<span className="font-mono font-semibold tabular-nums">
{analysis.matchesWithWinnerPatch.toLocaleString("en-US")}
</span>
{" / "}
<span className="font-mono tabular-nums">
{analysis.matchesWithLogFiles.toLocaleString("en-US")}
</span>
{winnerRatioPct != null ? (
<>
{" "}
(
<span className="font-mono tabular-nums">
{winnerRatioPct.toLocaleString("en-US", {
maximumFractionDigits: 1,
})}
%
</span>
)
</>
) : null}
</p>
<p className="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400">
A match ended when its log contains{" "}
<span className="font-mono">
Dedicated match winner PATCH success
</span>
.
</p>
</div>
{analysis.notEndedMatchIds.length > 0 ? (
<AnalysisMatchAlertBox
category="notEnded"
matchIds={analysis.notEndedMatchIds}
title="Not ended"
description="Log file present but no winner PATCH line — the match did not finish normally."
/>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line.
</p>
)}
</section>
) : null}
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping latency analytics
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Visual summary of current ping health and worst latency countries in this filter.
</p>
</div>
{pingAnalytics.error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load ping reports: {pingAnalytics.error}
</div>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat
label="Ping reports"
value={reports.toLocaleString("en-US")}
/>
<Stat label="Average ping" value={fmtMs(avgPing)} />
<Stat label="P95 ping" value={fmtMs(p95Ping)} />
<Stat
label={`Bad ping >= ${badThreshold}ms`}
value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`}
/>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping quality split
</h4>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<SplitBar
label={`Below ${badThreshold}ms`}
value={goodCount}
total={reports}
colorClassName="bg-emerald-500"
/>
<SplitBar
label={`${badThreshold}ms or above`}
value={badReports}
total={reports}
colorClassName="bg-rose-500"
/>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping breakdown
</h4>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setPingView("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By matches
</button>
<button
type="button"
onClick={() => setPingView("players")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "players"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By players
</button>
<button
type="button"
onClick={() => setPingView("countries")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "countries"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By country
</button>
</div>
</div>
{pingRows.length === 0 ? (
<p className="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
No ping data for this filter.
</p>
) : (
<div className="mt-3 space-y-2">
{pingRows.map((row) => (
<PingEntryBar
key={row.key}
label={row.label}
minPing={row.minPing}
avgPing={row.avgPing}
maxPing={row.maxPing}
/>
))}
</div>
)}
</div>
</>
)}
</section>
<AnalysisGeolocationMap
userAccounts={geolocationAnalytics.userAccounts}
matches={geolocationAnalytics.matches}
error={geolocationAnalytics.error}
/>
</section>
);
}
function SplitBar({
label,
value,
total,
colorClassName,
}: {
label: string;
value: number;
total: number;
colorClassName: string;
}) {
const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-zinc-600 dark:text-zinc-300">
<span>{label}</span>
<span className="font-mono tabular-nums">
{value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%)
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className={`h-2.5 rounded-full ${colorClassName}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function PingEntryBar({
label,
minPing,
avgPing,
maxPing,
}: {
label: string;
minPing: number;
avgPing: number;
maxPing: number;
}) {
const clamped = Math.max(0, Math.min(400, avgPing));
const widthPct = (clamped / 400) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-mono text-zinc-700 dark:text-zinc-300">{label}</span>
<span className="font-mono tabular-nums text-zinc-600 dark:text-zinc-400">
min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className="h-2.5 rounded-full bg-amber-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import type { NavPageAccess } from "@/lib/auth/permissions";
export type AdminNavTab =
| "overview"
| "players"
| "matches"
| "analysis"
| "matchmaker"
| "ledger"
| "logs"
| "founders-card"
| "settings";
type Props = {
active: AdminNavTab;
pageAccess: NavPageAccess;
isAdmin?: boolean;
playersLabel?: string;
matchesLabel?: string;
};
function tabClass(active: boolean) {
return [
"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(" ");
}
export function AdminNavTabs({
active,
pageAccess,
isAdmin = false,
playersLabel = "Players",
matchesLabel = "Matches",
}: Props) {
return (
<nav
className="flex flex-wrap gap-2 border-t border-zinc-200 px-6 py-3 dark:border-zinc-800"
aria-label="Admin pages"
>
<Link href="/" className={tabClass(active === "overview")}>
Overview
</Link>
{pageAccess.players ? (
<Link
href="/?tab=players"
className={tabClass(active === "players")}
>
{playersLabel}
</Link>
) : null}
{pageAccess.matches ? (
<Link
href="/?tab=matches"
className={tabClass(active === "matches")}
>
{matchesLabel}
</Link>
) : null}
{pageAccess.analysis ? (
<Link
href="/?tab=analysis"
className={tabClass(active === "analysis")}
>
Analysis
</Link>
) : null}
{pageAccess.matchmaker ? (
<Link
href="/?tab=matchmaker"
className={tabClass(active === "matchmaker")}
>
Matchmaker
</Link>
) : null}
{pageAccess.ledger ? (
<Link
href="/?tab=ledger"
className={tabClass(active === "ledger")}
>
Ledger
</Link>
) : null}
{pageAccess.logs ? (
<Link href="/?tab=logs" className={tabClass(active === "logs")}>
System logs
</Link>
) : null}
{pageAccess.foundersCard ? (
<Link
href="/founders-card-design"
className={tabClass(active === "founders-card")}
>
Founders card
</Link>
) : null}
{isAdmin ? (
<Link href="/settings" className={tabClass(active === "settings")}>
Settings
</Link>
) : null}
</nav>
);
}
+256 -131
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import {
insertSetting,
deleteSetting,
updateSetting,
} from "@/app/actions/settings-actions";
import {
@@ -17,42 +18,75 @@ type Props = {
rows: DbSetting[];
saveError: boolean;
addError: string | null;
readOnly?: boolean;
};
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
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}`}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
{row.key}
</p>
<p className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-300">
{row.value?.trim() ? row.value : "—"}
</p>
</div>
);
}
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
const [value, setValue] = useState(row.value ?? "");
const saveDisabled = value.trim() === "";
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<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"
value={value}
onChange={(e) => setValue(e.target.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"
disabled={saveDisabled}
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 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
<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"
/>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<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"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -64,50 +98,64 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
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}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`bet-fee-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
0100 (saved as the number shown).
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<input
id={`bet-fee-${index}`}
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(Number(e.target.value))}
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
/>
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{v}
</span>
</label>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
0100 (saved as the number shown).
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<input
id={`bet-fee-${index}`}
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(Number(e.target.value))}
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
/>
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{v}
</span>
</div>
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<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"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -123,79 +171,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
return rcToCoins(Number(t));
}, [rcText]);
const saveDisabled = rcText.trim() === "";
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError("Enter an RC value (one decimal place; tenths digit 03).");
return;
}
const rc = Number(trimmed);
const coins = rcToCoins(rc);
if (coins === null) {
e.preventDefault();
setLocalError(
"Enter a valid RC with one decimal place; the tenths digit must be 03 (e.g. 5.1).",
);
}
}}
>
<input type="hidden" name="key" value={row.key} />
<input
type="hidden"
name="value"
value={coinsPreview === null ? "" : String(coinsPreview)}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`entry-fee-rc-${index}`}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form
action={updateSetting}
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError(
"Enter an RC value (one decimal place; tenths digit 03).",
);
return;
}
const rc = Number(trimmed);
const coins = rcToCoins(rc);
if (coins === null) {
e.preventDefault();
setLocalError(
"Enter a valid RC with one decimal place; the tenths digit must be 03 (e.g. 5.1).",
);
}
}}
>
<input type="hidden" name="key" value={row.key} />
<input
type="hidden"
name="value"
value={coinsPreview === null ? "" : String(coinsPreview)}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`entry-fee-rc-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
(edit as RC; stored as coins)
</span>
</label>
<input
id={`entry-fee-rc-${index}`}
type="text"
inputMode="decimal"
autoComplete="off"
value={rcText}
onChange={(e) => setRcText(e.target.value)}
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{localError ? (
<p
className="mt-2 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{localError}
</p>
) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
the database.
</p>
) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Not a valid RC encoding yet fix the value to save.
</p>
) : null}
</div>
<button
type="submit"
disabled={saveDisabled}
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 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
<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}
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<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"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -213,7 +285,52 @@ export function AdminSettingsEditor({
rows,
saveError,
addError,
readOnly = false,
}: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
if (readOnly) {
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<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"
>
Settings are view-only for your account. Contact an admin to make
changes.
</div>
<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.
</p>
) : (
<div className="space-y-3">
{rows.map((row) => (
<ReadOnlySettingRow key={row.key} row={row} />
))}
</div>
)}
</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>
);
}
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
@@ -255,6 +372,10 @@ export function AdminSettingsEditor({
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty key.
</p>
) : addError === "missingValue" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty value.
</p>
) : addError === "config" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Supabase admin client is not configured.
@@ -277,7 +398,8 @@ export function AdminSettingsEditor({
id="new-setting-key"
name="newKey"
type="text"
required
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
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"
@@ -294,6 +416,8 @@ export function AdminSettingsEditor({
id="new-setting-value"
name="newValue"
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
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"
/>
@@ -301,6 +425,7 @@ export function AdminSettingsEditor({
<div className="flex justify-end">
<button
type="submit"
disabled={newKey.trim() === "" || newValue.trim() === ""}
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
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import { LocalTimestamp } from "@/components/local-timestamp";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
type Props = {
entries: AuditLogEntry[];
error: string | null;
};
const ACTION_LABELS: Record<string, string> = {
access: "Page access",
"auth.login": "Sign in",
"auth.logout": "Sign out",
"players.update_cc_rc": "Update player CC/RC",
"ledger.system_supply": "System supply",
"settings.update": "Update setting",
"settings.insert": "Create setting",
"settings.delete": "Delete setting",
"accounts.create": "Create account",
"accounts.update": "Update account",
"accounts.delete": "Delete account",
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
function isAuthAction(action: string): boolean {
return action === "auth.login" || action === "auth.logout";
}
function isAccessAction(action: string): boolean {
return action === "access";
}
/** Prefer top-level fields; fall back to details for older/partial entries. */
function entryIp(entry: AuditLogEntry): string | null {
if (entry.ip) return entry.ip;
const d = entry.details?.ip;
return typeof d === "string" ? d : null;
}
function entryDevice(entry: AuditLogEntry): string | null {
if (entry.device) return entry.device;
const d = entry.details?.device;
return typeof d === "string" ? d : null;
}
function entryUserAgent(entry: AuditLogEntry): string | null {
if (entry.userAgent) return entry.userAgent;
const d = entry.details?.userAgent;
return typeof d === "string" ? d : null;
}
function entryPage(entry: AuditLogEntry): string | null {
const d = entry.details?.page;
return typeof d === "string" ? d : null;
}
function matchesQuery(entry: AuditLogEntry, q: string): boolean {
if (!q) return true;
const hay = [
entry.username,
entry.action,
actionLabel(entry.action),
entry.summary,
entryIp(entry) ?? "",
entryDevice(entry) ?? "",
entryUserAgent(entry) ?? "",
entryPage(entry) ?? "",
entryPage(entry) ? pageAccessLabel(entryPage(entry)!) : "",
entry.details ? JSON.stringify(entry.details) : "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
export function AdminSystemLogs({ entries, error }: Props) {
const [query, setQuery] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [accountFilter, setAccountFilter] = useState("");
const actionOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) set.add(e.action);
for (const known of Object.keys(ACTION_LABELS)) set.add(known);
return [...set].sort((a, b) =>
actionLabel(a).localeCompare(actionLabel(b)),
);
}, [entries]);
const accountOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) {
if (e.username) set.add(e.username);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [entries]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((entry) => {
if (actionFilter && entry.action !== actionFilter) return false;
if (accountFilter && entry.username !== accountFilter) return false;
return matchesQuery(entry, q);
});
}, [entries, query, actionFilter, accountFilter]);
const filtersActive = Boolean(query.trim() || actionFilter || accountFilter);
return (
<section className="mx-auto w-full max-w-[1400px] space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
System logs
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Panel actions, page access, and who performed them including login
IP and device. Newest first (up to 1,000 entries).
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-[200px] flex-1">
<label
htmlFor="logs-search"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Search
</label>
<input
id="logs-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search summary, IP, device…"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm 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"
/>
</div>
<div className="w-full sm:w-52">
<label
htmlFor="logs-action"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Action
</label>
<select
id="logs-action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm 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"
>
<option value="">All actions</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{actionLabel(action)}
</option>
))}
</select>
</div>
<div className="w-full sm:w-44">
<label
htmlFor="logs-account"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Account
</label>
<select
id="logs-account"
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm 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"
>
<option value="">All accounts</option>
{accountOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{filtersActive ? (
<button
type="button"
onClick={() => {
setQuery("");
setActionFilter("");
setAccountFilter("");
}}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Clear
</button>
) : null}
</div>
{error ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{error}
</div>
) : null}
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filtered.length.toLocaleString("en-US")} of{" "}
{entries.length.toLocaleString("en-US")}
{filtersActive ? " (filtered)" : ""}
</p>
{entries.length === 0 && !error ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No actions logged yet.
</p>
) : filtered.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No log entries match these filters.
</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="w-full min-w-[900px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400">
<th className="px-4 py-3 font-medium">When</th>
<th className="px-4 py-3 font-medium">Who</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Device</th>
</tr>
</thead>
<tbody>
{filtered.map((entry) => {
const ip = entryIp(entry);
const device = entryDevice(entry);
const userAgent = entryUserAgent(entry);
const page = entryPage(entry);
const showClientMeta =
isAuthAction(entry.action) ||
isAccessAction(entry.action) ||
Boolean(ip || device);
return (
<tr
key={entry.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-zinc-700 dark:text-zinc-300">
<LocalTimestamp value={entry.at} />
</td>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-50">
{entry.username}
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
<span className="font-medium">
{actionLabel(entry.action)}
</span>
<span className="mt-0.5 block font-mono text-xs text-zinc-500 dark:text-zinc-400">
{entry.action}
{page ? ` · ${page}` : ""}
</span>
</td>
<td className="max-w-md px-4 py-3 text-zinc-700 dark:text-zinc-300">
<p>{entry.summary}</p>
{entry.details &&
Object.keys(entry.details).length > 0 &&
!isAuthAction(entry.action) &&
!isAccessAction(entry.action) ? (
<pre className="mt-1 max-h-24 overflow-auto rounded bg-zinc-50 px-2 py-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
{JSON.stringify(entry.details, null, 0)}
</pre>
) : null}
{(isAuthAction(entry.action) ||
isAccessAction(entry.action)) &&
userAgent ? (
<p
className="mt-1 break-all font-mono text-[11px] text-zinc-500 dark:text-zinc-400"
title={userAgent}
>
{userAgent}
</p>
) : null}
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{showClientMeta ? (ip ?? "—") : "—"}
</td>
<td className="max-w-[220px] px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{showClientMeta ? (device ?? "—") : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
+360
View File
@@ -0,0 +1,360 @@
"use client";
import { useMemo, useState } from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import {
countryDisplayName,
heatmapColorForCount,
NO_DATA_FILL,
NO_DATA_FILL_DARK,
} from "@/lib/heatmap-color";
import type { GeolocationCountryStat } from "@/lib/geolocation-analytics";
const GEO_URL =
"https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson";
type MapMode = "userAccounts" | "matches";
type Props = {
userAccounts: GeolocationCountryStat[];
matches: GeolocationCountryStat[];
error: string | null;
};
type TooltipState = {
countryCode: string;
x: number;
y: number;
} | null;
function countryCodeFromGeo(geo: {
properties?: Record<string, unknown>;
id?: string | number;
}): string | null {
const props = geo.properties ?? {};
const candidates = [
props.ISO_A2,
props.iso_a2,
props.ISO3166_1_Alpha_2,
props["ISO3166-1-Alpha-2"],
geo.id,
];
for (const candidate of candidates) {
const code = String(candidate ?? "")
.trim()
.toUpperCase();
if (/^[A-Z]{2}$/.test(code)) return code;
}
return null;
}
function buildCountMap(rows: GeolocationCountryStat[]): Map<string, GeolocationCountryStat> {
const map = new Map<string, GeolocationCountryStat>();
for (const row of rows) {
map.set(row.countryCode.toUpperCase(), row);
}
return map;
}
function countRange(rows: GeolocationCountryStat[]): {
min: number;
max: number;
} {
if (rows.length === 0) return { min: 0, max: 0 };
const counts = rows.map((r) => r.count);
return { min: Math.min(...counts), max: Math.max(...counts) };
}
export function AnalysisGeolocationMap({
userAccounts,
matches,
error,
}: Props) {
const [mode, setMode] = useState<MapMode>("userAccounts");
const [tooltip, setTooltip] = useState<TooltipState>(null);
const activeRows = mode === "userAccounts" ? userAccounts : matches;
const countByCountry = useMemo(() => buildCountMap(activeRows), [activeRows]);
const { min: minCount, max: maxCount } = useMemo(
() => countRange(activeRows),
[activeRows],
);
const totalCount = activeRows.reduce((acc, row) => acc + row.count, 0);
const countriesWithData = activeRows.length;
const tooltipStat = tooltip
? countByCountry.get(tooltip.countryCode.toUpperCase())
: null;
return (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Player geolocation
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
World heatmap by country blue is fewest, red is most. Gray means no
players in that country.
</p>
</div>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setMode("userAccounts")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "userAccounts"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
User accounts
</button>
<button
type="button"
onClick={() => setMode("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
Matches
</button>
</div>
</div>
{error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load geolocation data: {error}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
label={mode === "userAccounts" ? "Users mapped" : "Matches mapped"}
value={totalCount.toLocaleString("en-US")}
/>
<StatPill
label="Countries"
value={countriesWithData.toLocaleString("en-US")}
/>
<StatPill
label="Range per country"
value={
maxCount > 0
? `${minCount.toLocaleString("en-US")}${maxCount.toLocaleString("en-US")}`
: "—"
}
/>
</div>
<div className="relative overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950/40">
<ComposableMap
projection="geoEqualEarth"
projectionConfig={{ scale: 145 }}
width={800}
height={420}
style={{ width: "100%", height: "auto" }}
>
<ZoomableGroup center={[0, 10]} zoom={1} minZoom={1} maxZoom={4}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => {
const countryCode = countryCodeFromGeo(geo);
const stat = countryCode
? countByCountry.get(countryCode)
: undefined;
const count = stat?.count ?? 0;
const fill =
count > 0
? heatmapColorForCount(count, minCount, maxCount)
: undefined;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fill ?? NO_DATA_FILL}
stroke="#a1a1aa"
strokeWidth={0.25}
style={{
default: {
outline: "none",
transition: "fill 150ms ease",
},
hover: {
outline: "none",
fill:
fill ??
(typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? NO_DATA_FILL_DARK
: NO_DATA_FILL),
filter: count > 0 ? "brightness(1.08)" : "none",
cursor: countryCode ? "pointer" : "default",
},
pressed: { outline: "none" },
}}
onMouseEnter={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseMove={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && tooltipStat ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-700 dark:text-zinc-300">
{mode === "userAccounts" ? "Users" : "Matches"}:{" "}
<span className="font-mono tabular-nums">
{tooltipStat.count.toLocaleString("en-US")}
</span>
</p>
{mode === "matches" && tooltipStat.avgPing != null ? (
<p className="text-zinc-700 dark:text-zinc-300">
Avg ping:{" "}
<span className="font-mono tabular-nums">
{Math.round(tooltipStat.avgPing).toLocaleString("en-US")} ms
</span>
</p>
) : null}
</div>
) : tooltip ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-500">No data</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span>Low</span>
<div
className="h-2.5 flex-1 min-w-[8rem] rounded-full"
style={{
background:
"linear-gradient(to right, hsl(220, 68%, 42%), hsl(120, 68%, 42%), hsl(0, 68%, 42%))",
}}
/>
<span>High</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: NO_DATA_FILL }}
/>
No players
</span>
</div>
{activeRows.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-50 text-zinc-500 dark:bg-zinc-950/60 dark:text-zinc-400">
<tr>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Country
</th>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
{mode === "userAccounts" ? "Users" : "Matches"}
</th>
{mode === "matches" ? (
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Avg ping
</th>
) : null}
</tr>
</thead>
<tbody>
{activeRows.slice(0, 12).map((row) => (
<tr
key={row.countryCode}
className="border-t border-zinc-200 dark:border-zinc-800"
>
<td className="px-3 py-2 text-zinc-800 dark:text-zinc-200">
{countryDisplayName(row.countryCode)}{" "}
<span className="font-mono text-zinc-500">
({row.countryCode})
</span>
</td>
<td className="px-3 py-2 font-mono tabular-nums">
{row.count.toLocaleString("en-US")}
</td>
{mode === "matches" ? (
<td className="px-3 py-2 font-mono tabular-nums">
{row.avgPing != null
? `${Math.round(row.avgPing).toLocaleString("en-US")} ms`
: "—"}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No geolocation data for this mode and filter.
</p>
)}
</section>
);
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useState,
type MouseEvent,
type ReactNode,
} from "react";
import {
EMPTY_HIDDEN_MIRROR,
EMPTY_HIDDEN_NOT_ENDED,
getHiddenAnalysisMatchIds,
hideAnalysisMatchIds,
subscribeAnalysisHiddenAlerts,
type AnalysisAlertCategory,
} from "@/lib/analysis-alert-hide-cookies";
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
className={className ?? "h-3.5 w-3.5"}
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
);
}
const STYLES: Record<
AnalysisAlertCategory,
{
box: string;
body: string;
chip: string;
dismiss: string;
headerDismiss: string;
}
> = {
mirror: {
box: "rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-950 dark:border-red-800 dark:bg-red-950/50 dark:text-red-100",
body: "text-red-800 dark:text-red-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-red-400/80 bg-white shadow-sm dark:border-red-700 dark:bg-red-950/80",
dismiss:
"shrink-0 p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-red-800 hover:bg-red-100 dark:text-red-100 dark:hover:bg-red-900/80",
},
notEnded: {
box: "rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100",
body: "text-amber-900/90 dark:text-amber-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-amber-400/80 bg-white shadow-sm dark:border-amber-700 dark:bg-amber-950/80",
dismiss:
"shrink-0 p-1 text-amber-800 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-amber-900 hover:bg-amber-100 dark:text-amber-50 dark:hover:bg-amber-900/80",
},
};
function emptyHiddenFor(category: AnalysisAlertCategory): number[] {
return category === "mirror" ? EMPTY_HIDDEN_MIRROR : EMPTY_HIDDEN_NOT_ENDED;
}
function useHiddenMatchIds(category: AnalysisAlertCategory): number[] {
const [hiddenIds, setHiddenIds] = useState<number[]>(() =>
emptyHiddenFor(category),
);
useEffect(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
return subscribeAnalysisHiddenAlerts(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
});
}, [category]);
return hiddenIds;
}
type Props = {
category: AnalysisAlertCategory;
matchIds: number[];
title: string;
description: ReactNode;
};
export function AnalysisMatchAlertBox({
category,
matchIds,
title,
description,
}: Props) {
const hiddenIds = useHiddenMatchIds(category);
const hiddenSet = useMemo(() => new Set(hiddenIds), [hiddenIds]);
const visibleIds = useMemo(
() => matchIds.filter((id) => !hiddenSet.has(id)),
[matchIds, hiddenSet],
);
const hideOne = useCallback(
(e: MouseEvent<HTMLButtonElement>, matchId: number) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, [matchId]);
},
[category],
);
const hideAll = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, visibleIds);
},
[category, visibleIds],
);
if (matchIds.length === 0 || visibleIds.length === 0) return null;
const s = STYLES[category];
return (
<div className={s.box} role="alert">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
{title} ({visibleIds.length.toLocaleString("en-US")}{" "}
{visibleIds.length === 1 ? "match" : "matches"})
</p>
<p className={`mt-1 ${s.body}`}>{description}</p>
</div>
<button
type="button"
onClick={hideAll}
className={s.headerDismiss}
aria-label="Hide all matches in this list"
title="Hide all"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<ul className="mt-3 flex flex-wrap gap-2">
{visibleIds.map((matchId) => (
<li key={matchId}>
<span className={s.chip}>
<Link
href={`/match-logs/${matchId}`}
className={`px-2.5 py-1 font-mono text-xs font-medium ${
category === "mirror"
? "text-red-900 dark:text-red-50"
: "text-amber-950 dark:text-amber-50"
}`}
>
Match {matchId}
</Link>
<button
type="button"
onClick={(e) => hideOne(e, matchId)}
className={s.dismiss}
aria-label={`Hide match ${matchId}`}
title="Hide"
>
<CloseIcon />
</button>
</span>
</li>
))}
</ul>
</div>
);
}
+3 -1
View File
@@ -90,7 +90,9 @@ export function EditUserCcRcOverlay({
? "matchmaker"
: tab === "ledger"
? "ledger"
: "dashboard"
: tab === "analysis"
? "analysis"
: "dashboard"
}
/>
<input
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { formatLocalTimestamp } from "@/lib/local-date-range";
type Props = {
value: string | null;
className?: string;
};
/**
* Renders timestamps in the browser's local timezone.
* Avoids SSR/client hydration mismatch by filling in after mount.
*/
export function LocalTimestamp({ value, className }: Props) {
const [label, setLabel] = useState("—");
useEffect(() => {
setLabel(formatLocalTimestamp(value));
}, [value]);
if (!value) {
return <span className={className}></span>;
}
return (
<time dateTime={value} className={className} suppressHydrationWarning>
{label}
</time>
);
}
@@ -0,0 +1,96 @@
"use client";
import type { EmoteBarRow } from "@/lib/match-log-parser";
const ROW_H = 22;
const BAR_MAX_W = 180;
const PAD = 4;
type Props = {
rows: EmoteBarRow[];
/** e.g. "Text emotes" */
title: string;
barClassName?: string;
/** Wider labels for emoji glyphs + `[id]` (default 120). */
labelWidth?: number;
/** Max label characters before ellipsis (default 16). */
labelMaxLen?: number;
/** Use monospace for label text (default true). */
labelMonospace?: boolean;
};
export function MatchAnalysisEmoteChart({
rows,
title,
barClassName = "fill-violet-500 dark:fill-violet-400",
labelWidth = 120,
labelMaxLen = 16,
labelMonospace = true,
}: Props) {
if (rows.length === 0) {
return (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No {title.toLowerCase()} in range.
</p>
);
}
const max = Math.max(...rows.map((r) => r.count), 1);
const vbH = PAD * 2 + rows.length * ROW_H;
const vbW = labelWidth + BAR_MAX_W + 48;
return (
<figure aria-label={`${title} bar chart`}>
<svg
viewBox={`0 0 ${vbW} ${vbH}`}
className="h-auto w-full max-w-lg text-zinc-800 dark:text-zinc-200"
role="img"
>
{rows.map((row, i) => {
const y = PAD + i * ROW_H + ROW_H / 2;
const barW = (row.count / max) * BAR_MAX_W;
const label =
row.label.length > labelMaxLen
? `${row.label.slice(0, labelMaxLen - 1)}`
: row.label;
return (
<g key={row.label}>
<text
x={0}
y={y}
dominantBaseline="middle"
className={`fill-zinc-600 dark:fill-zinc-400 ${
labelMonospace ? "text-[9px]" : "text-[11px]"
}`}
style={
labelMonospace
? { fontFamily: "ui-monospace, monospace" }
: undefined
}
>
{label}
</text>
<rect
x={labelWidth}
y={y - 6}
width={barW}
height={12}
rx={2}
className={barClassName}
/>
<text
x={labelWidth + BAR_MAX_W + 6}
y={y}
dominantBaseline="middle"
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
style={{ fontFamily: "ui-monospace, monospace" }}
>
{row.count.toLocaleString("en-US")}
</text>
</g>
);
})}
</svg>
</figure>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
const VB = 320;
const PAD = 18;
type Point = { x: number; y: number };
function extent(values: number[]): { min: number; max: number } {
if (values.length === 0) return { min: -1, max: 1 };
let min = values[0]!;
let max = values[0]!;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
if (min === max) {
const pad = Math.abs(min) * 0.1 + 1;
return { min: min - pad, max: max + pad };
}
const margin = (max - min) * 0.05;
return { min: min - margin, max: max + margin };
}
type Props = {
points: Point[];
};
export function MatchAnalysisForceChart({ points }: Props) {
const [hover, setHover] = useState<Point | null>(null);
const { dots, xExt, yExt } = useMemo(() => {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const xExt = extent(xs);
const yExt = extent(ys);
const inner = VB - PAD * 2;
const dots = points.map((p) => {
const tx =
xExt.max === xExt.min
? 0.5
: (p.x - xExt.min) / (xExt.max - xExt.min);
const ty =
yExt.max === yExt.min
? 0.5
: (p.y - yExt.min) / (yExt.max - yExt.min);
return {
cx: PAD + tx * inner,
cy: PAD + (1 - ty) * inner,
raw: p,
};
});
return { dots, xExt, yExt };
}, [points]);
if (points.length === 0) {
return (
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
No shot force vectors in the selected matches.
</p>
);
}
return (
<figure aria-label="Force vector scatter plot (x and y components)">
<div
className="relative cursor-crosshair"
onPointerLeave={() => setHover(null)}
>
<svg
viewBox={`0 0 ${VB} ${VB}`}
className="h-auto w-full max-w-md text-sky-600 dark:text-sky-400"
role="img"
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const sx =
((e.clientX - rect.left) / rect.width) * VB;
const sy =
((e.clientY - rect.top) / rect.height) * VB;
let best: (typeof dots)[0] | null = null;
let bestD = 12;
for (const d of dots) {
const dist = Math.hypot(d.cx - sx, d.cy - sy);
if (dist < bestD) {
bestD = dist;
best = d;
}
}
setHover(best?.raw ?? null);
}}
>
<rect
x={PAD}
y={PAD}
width={VB - PAD * 2}
height={VB - PAD * 2}
className="fill-zinc-50 stroke-zinc-200 dark:fill-zinc-900/50 dark:stroke-zinc-700"
strokeWidth={1}
/>
<line
x1={PAD + (VB - PAD * 2) / 2}
y1={PAD}
x2={PAD + (VB - PAD * 2) / 2}
y2={VB - PAD}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
<line
x1={PAD}
y1={PAD + (VB - PAD * 2) / 2}
x2={VB - PAD}
y2={PAD + (VB - PAD * 2) / 2}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
{dots.map((d, i) => (
<circle
key={i}
cx={d.cx}
cy={d.cy}
r={hover === d.raw ? 3.5 : 2}
className="fill-current opacity-70"
/>
))}
</svg>
{hover ? (
<div
className="pointer-events-none absolute left-2 top-2 rounded-md border border-zinc-200 bg-white/95 px-2 py-1 text-[10px] font-mono tabular-nums shadow-sm dark:border-zinc-700 dark:bg-zinc-900/95"
role="status"
>
({hover.x.toFixed(1)}, {hover.y.toFixed(1)})
</div>
) : null}
</div>
<figcaption className="mt-2 flex justify-between gap-2 font-mono text-[10px] text-zinc-500 dark:text-zinc-400">
<span>
Fx {xExt.min.toFixed(0)} {xExt.max.toFixed(0)}
</span>
<span>
Fy {yExt.min.toFixed(0)} {yExt.max.toFixed(0)}
</span>
</figcaption>
<p className="mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{points.length.toLocaleString("en-US")} shots · magnitude (x² + y²)
</p>
</figure>
);
}
+72 -45
View File
@@ -1,43 +1,23 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
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;
/** `matches.entry_fee` as coin string (bigint); RC shown in footer. */
entryFeeCoins: string | null;
/** Ledger `entry_hold` / player as coin string; null if unknown. */
entryHoldPerPlayerCoins: string | null;
/** Preformatted `prize_cc` for display (e.g. with grouping). */
prizeCcLabel: string;
left: PlayerSide;
right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null;
};
function rcLabelFromCoinString(s: string | null): string {
if (s == null || String(s).trim() === "") return "—";
/** Ledger rows are per player; both players pay → show `1.0 RC x 2 = 2.0 RC`. */
function rcPerPlayerTimesTwoLine(coinStr: string | null): string {
if (coinStr == null || String(coinStr).trim() === "") return "—";
try {
return formatRcLabelFromCoinsBigInt(BigInt(String(s).trim()));
const perPlayer = BigInt(String(coinStr).trim());
const bothPlayers = perPlayer * BigInt(2);
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
} catch {
return "—";
}
}
function entryTotalRcLabel(
function entryCombinedPerPlayerTimesTwoLine(
feeCoins: string | null,
holdCoins: string | null,
): string {
@@ -50,13 +30,40 @@ function entryTotalRcLabel(
return "—";
}
try {
const sum = BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
return formatRcLabelFromCoinsBigInt(sum);
const perPlayer =
BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
const bothPlayers = perPlayer * BigInt(2);
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
} catch {
return "—";
}
}
type PlayerSide = {
id: number | null;
username: string | null;
cc: number | null;
rc: number | null;
color: "red" | "blue";
};
type Props = {
matchId: number;
statusLabel: string;
createdAtLabel: ReactNode;
/** Ledger `entry_fee` / player as coin string; null if unknown. */
entryFeeCoins: string | null;
/** Ledger `entry_hold` / player as coin string; null if unknown. */
entryHoldPerPlayerCoins: string | null;
/** Preformatted `prize_cc` for display (e.g. with grouping). */
prizeCcLabel: string;
left: PlayerSide;
right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null;
hasReplay: boolean;
};
function idsMatch(
a: number | string | null | undefined,
b: number | string | null | undefined,
@@ -184,10 +191,14 @@ export function MatchHistoryBattleCard({
left,
right,
winnerId,
hasReplay,
}: Props) {
const entryRc = entryTotalRcLabel(entryFeeCoins, entryHoldPerPlayerCoins);
const entryFeeRc = rcLabelFromCoinString(entryFeeCoins);
const entryHoldRc = rcLabelFromCoinString(entryHoldPerPlayerCoins);
const entryLine = entryCombinedPerPlayerTimesTwoLine(
entryFeeCoins,
entryHoldPerPlayerCoins,
);
const entryHoldLine = rcPerPlayerTimesTwoLine(entryHoldPerPlayerCoins);
const entryFeeLine = rcPerPlayerTimesTwoLine(entryFeeCoins);
const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel;
const hasWinner =
winnerId != null &&
@@ -238,26 +249,42 @@ export function MatchHistoryBattleCard({
Time {createdAtLabel}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Entry {entryRc}
Entry {entryLine}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Entry hold {entryHoldRc}
Entry hold {entryHoldLine}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Prize {prizeCcDisplay} CC
</span>
<span className="rounded-md border border-emerald-200/90 bg-emerald-50 px-2 py-1 text-[11px] font-medium text-emerald-600 dark:border-emerald-300/35 dark:bg-emerald-400/15 dark:text-emerald-200">
Entry fee {entryFeeRc}
<span className="rounded-md border border-emerald-200/90 bg-emerald-50 px-2 py-1 text-[11px] font-medium text-emerald-600 tabular-nums dark:border-emerald-300/35 dark:bg-emerald-400/15 dark:text-emerald-200">
Entry fee {entryFeeLine}
</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 className="flex shrink-0 flex-wrap items-center gap-2">
{hasReplay ? (
<Link
href={`/replays/${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"
>
View replay
</Link>
) : (
<span className="px-1 text-xs text-zinc-400 dark:text-zinc-600">
No replay
</span>
)}
<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>
</div>
</article>
);
+26
View File
@@ -16,6 +16,7 @@ import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
import { countryFlagFromCode } from "@/lib/ip-geolocation";
import { usePlayerCard } from "@/components/player-card-context";
function formatTsUtc(value: string | null): string {
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
</div>
{load.status === "ok" ? (
<div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Email</span>
<span className="truncate font-mono text-zinc-800 dark:text-zinc-200">
{load.data.email?.trim() ? load.data.email.trim() : "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Account created</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
{formatTsUtc(load.data.lastSeen)}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Last logged in IP</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{load.data.lastLoggedInIp?.trim()
? load.data.lastLoggedInIp.trim()
: "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Country code</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{(() => {
const code = load.data.lastLoggedInCountryCode?.trim();
if (!code) return "—";
const flag = countryFlagFromCode(code);
return flag ? `${flag} ${code}` : code;
})()}
</span>
</div>
</div>
) : null}
</div>
+706
View File
@@ -0,0 +1,706 @@
"use client";
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { parseReplayJsonText } from "@/lib/replay-parse";
import {
discontinuityTimes,
eventCursorAfter,
formatReplayTime,
resolvePoses,
scoreAtTime,
} from "@/lib/replay-playback";
import type { ReplayEvent, ReplayFile } from "@/types/replay";
import {
REPLAY_BOTTOM_GOAL,
REPLAY_FIELD_BOUNDS,
REPLAY_PITCH_BOUNDS,
REPLAY_TOP_GOAL,
type ReplayGoalBox,
} from "@/types/replay";
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
/** Padding around the field rect inside the canvas (CSS px). */
const VIEW_PAD = 12;
type ViewRect = {
ox: number;
oy: number;
w: number;
h: number;
};
/** Letterbox the fixed field into the canvas, preserving aspect. */
function fieldViewRect(canvasW: number, canvasH: number): ViewRect {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
const availW = Math.max(1, canvasW - VIEW_PAD * 2);
const availH = Math.max(1, canvasH - VIEW_PAD * 2);
const scale = Math.min(availW / worldW, availH / worldH);
const w = worldW * scale;
const h = worldH * scale;
return {
ox: (canvasW - w) / 2,
oy: (canvasH - h) / 2,
w,
h,
};
}
function worldToCanvas(
x: number,
y: number,
view: ViewRect,
): { cx: number; cy: number } {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
return {
cx: view.ox + ((x - minX) / worldW) * view.w,
cy: view.oy + ((maxY - y) / worldH) * view.h, // +Y up → canvas Y down
};
}
function fillWorldRect(
ctx: CanvasRenderingContext2D,
view: ViewRect,
x0: number,
y0: number,
x1: number,
y1: number,
fill: string,
) {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.fillStyle = fill;
ctx.fillRect(
Math.min(a.cx, b.cx),
Math.min(a.cy, b.cy),
Math.abs(b.cx - a.cx),
Math.abs(b.cy - a.cy),
);
}
function strokeWorldRect(
ctx: CanvasRenderingContext2D,
view: ViewRect,
x0: number,
y0: number,
x1: number,
y1: number,
stroke: string,
lineWidth = 2,
) {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.strokeRect(
Math.min(a.cx, b.cx),
Math.min(a.cy, b.cy),
Math.abs(b.cx - a.cx),
Math.abs(b.cy - a.cy),
);
}
function goalExtents(g: ReplayGoalBox) {
const halfW = g.width / 2;
const halfH = g.height / 2;
return {
left: g.x - halfW,
right: g.x + halfW,
bottom: g.y - halfH,
top: g.y + halfH,
};
}
type Props = {
/** Optional preloaded replay (e.g. later: server file by match id). */
initialReplay?: ReplayFile | null;
initialFileName?: string | null;
};
function entityFill(type: string, team: string): string {
if (type === "ball") return "#f4f0e6";
if (team === "Red") return "#e5484d";
if (team === "Blue") return "#3b82f6";
return "#a1a1aa";
}
function entityStroke(type: string, team: string): string {
if (type === "ball") return "#c4b89a";
if (team === "Red") return "#9f1239";
if (team === "Blue") return "#1d4ed8";
return "#52525b";
}
function drawPitch(
ctx: CanvasRenderingContext2D,
canvasW: number,
canvasH: number,
view: ViewRect,
) {
const pitch = REPLAY_PITCH_BOUNDS;
const field = REPLAY_FIELD_BOUNDS;
const worldW = field.maxX - field.minX;
const worldH = field.maxY - field.minY;
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, canvasW, canvasH);
// Playable pitch + goal boxes (view letterboxes to include goals)
fillWorldRect(
ctx,
view,
pitch.minX,
pitch.minY,
pitch.maxX,
pitch.maxY,
"#1a3d2b",
);
const topG = goalExtents(REPLAY_TOP_GOAL);
const botG = goalExtents(REPLAY_BOTTOM_GOAL);
fillWorldRect(
ctx,
view,
topG.left,
pitch.maxY,
topG.right,
topG.top,
"#1a3d2b",
);
fillWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
pitch.minY,
"#1a3d2b",
);
// Subtle horizontal stripes on the playable pitch
const stripes = 10;
for (let i = 0; i < stripes; i++) {
if (i % 2 === 0) continue;
const y0 =
pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * (i + 1);
const y1 = pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * i;
fillWorldRect(
ctx,
view,
pitch.minX,
y0,
pitch.maxX,
y1,
"rgba(255,255,255,0.03)",
);
}
const strokeLine = (
x0: number,
y0: number,
x1: number,
y1: number,
width = 2,
) => {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.beginPath();
ctx.moveTo(a.cx, a.cy);
ctx.lineTo(b.cx, b.cy);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = width;
ctx.stroke();
};
// Sidelines
strokeLine(pitch.minX, pitch.minY, pitch.minX, pitch.maxY, 2.5);
strokeLine(pitch.maxX, pitch.minY, pitch.maxX, pitch.maxY, 2.5);
// Top end: end-line with goal mouth notch
strokeLine(pitch.minX, pitch.maxY, topG.left, pitch.maxY, 2.5);
strokeLine(topG.right, pitch.maxY, pitch.maxX, pitch.maxY, 2.5);
strokeLine(topG.left, pitch.maxY, topG.left, topG.top, 2.5);
strokeLine(topG.right, pitch.maxY, topG.right, topG.top, 2.5);
strokeLine(topG.left, topG.top, topG.right, topG.top, 2.5);
// Bottom end: end-line with goal mouth notch
strokeLine(pitch.minX, pitch.minY, botG.left, pitch.minY, 2.5);
strokeLine(botG.right, pitch.minY, pitch.maxX, pitch.minY, 2.5);
strokeLine(botG.left, pitch.minY, botG.left, botG.bottom, 2.5);
strokeLine(botG.right, pitch.minY, botG.right, botG.bottom, 2.5);
strokeLine(botG.left, botG.bottom, botG.right, botG.bottom, 2.5);
// Center line + circle
strokeLine(pitch.minX, 0, pitch.maxX, 0, 2);
const r = Math.min(worldW, worldH) * 0.14;
const c = worldToCanvas(0, 0, view);
const rx = (r / worldW) * view.w;
const ry = (r / worldH) * view.h;
ctx.beginPath();
ctx.ellipse(c.cx, c.cy, rx, ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.35)";
ctx.lineWidth = 2;
ctx.stroke();
// Goal fills (exact size/position)
fillWorldRect(
ctx,
view,
topG.left,
topG.bottom,
topG.right,
topG.top,
"rgba(229, 72, 77, 0.45)",
);
strokeWorldRect(
ctx,
view,
topG.left,
topG.bottom,
topG.right,
topG.top,
"rgba(229, 72, 77, 0.85)",
1.5,
);
fillWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
botG.top,
"rgba(59, 130, 246, 0.45)",
);
strokeWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
botG.top,
"rgba(59, 130, 246, 0.85)",
1.5,
);
}
function drawEntities(
ctx: CanvasRenderingContext2D,
replay: ReplayFile,
poses: { x: number; y: number }[],
view: ViewRect,
) {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
const unit = Math.min(view.w / worldW, view.h / worldH);
const ballR = Math.max(4, unit * 0.12);
const puckR = Math.max(6, unit * 0.18);
for (let i = 0; i < replay.entities.length; i++) {
const ent = replay.entities[i]!;
const pose = poses[i];
if (!pose) continue;
const { cx, cy } = worldToCanvas(pose.x, pose.y, view);
const r = ent.type === "ball" ? ballR : puckR;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = entityFill(ent.type, ent.team);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = entityStroke(ent.type, ent.team);
ctx.stroke();
if (ent.type === "ball") {
ctx.beginPath();
ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,0.12)";
ctx.fill();
}
}
}
function eventMarkerColor(e: ReplayEvent): string {
if (e.type === "goal") {
return e.team === "Red" ? "#3b82f6" : "#e5484d";
}
if (e.type === "reset") return "#a1a1aa";
return "transparent";
}
export function ReplayViewer({
initialReplay = null,
initialFileName = null,
}: Props) {
const [replay, setReplay] = useState<ReplayFile | null>(initialReplay);
const [fileName, setFileName] = useState<string | null>(initialFileName);
const [error, setError] = useState<string | null>(null);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const [time, setTime] = useState(0);
const [score, setScore] = useState({ red: 0, blue: 0 });
const [dragOver, setDragOver] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cutsRef = useRef<number[]>(
initialReplay ? discontinuityTimes(initialReplay.events) : [],
);
const timeRef = useRef(0);
const playingRef = useRef(false);
const speedRef = useRef(1);
const lastTsRef = useRef<number | null>(null);
const eventCursorRef = useRef(0);
const replayRef = useRef<ReplayFile | null>(initialReplay);
useEffect(() => {
timeRef.current = time;
}, [time]);
useEffect(() => {
playingRef.current = playing;
}, [playing]);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
useEffect(() => {
replayRef.current = replay;
}, [replay]);
const loadReplay = useCallback((file: ReplayFile, name: string) => {
setReplay(file);
setFileName(name);
setError(null);
setTime(0);
timeRef.current = 0;
setPlaying(false);
setScore({ red: 0, blue: 0 });
cutsRef.current = discontinuityTimes(file.events);
eventCursorRef.current = 0;
}, []);
const onFileText = useCallback(
(text: string, name: string) => {
try {
const parsed = parseReplayJsonText(text);
loadReplay(parsed, name);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to parse replay");
setReplay(null);
setFileName(null);
}
},
[loadReplay],
);
const onPickFile = useCallback(
(file: File | null) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = typeof reader.result === "string" ? reader.result : "";
onFileText(text, file.name);
};
reader.onerror = () => setError("Could not read file");
reader.readAsText(file);
},
[onFileText],
);
const seekTo = useCallback((t: number) => {
const r = replayRef.current;
if (!r) return;
const clamped = Math.min(Math.max(0, t), r.duration);
timeRef.current = clamped;
setTime(clamped);
setScore(scoreAtTime(r.events, clamped));
eventCursorRef.current = eventCursorAfter(r.events, clamped);
}, []);
const paint = useEffectEvent(() => {
const canvas = canvasRef.current;
const r = replayRef.current;
if (!canvas || !r) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
if (cssW < 2 || cssH < 2) return;
const needW = Math.floor(cssW * dpr);
const needH = Math.floor(cssH * dpr);
if (canvas.width !== needW || canvas.height !== needH) {
canvas.width = needW;
canvas.height = needH;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const view = fieldViewRect(cssW, cssH);
const poses = resolvePoses(
r.frames,
r.entities.length,
timeRef.current,
cutsRef.current,
);
drawPitch(ctx, cssW, cssH, view);
drawEntities(ctx, r, poses, view);
});
useEffect(() => {
let raf = 0;
const tick = (ts: number) => {
const r = replayRef.current;
if (r && playingRef.current) {
if (lastTsRef.current == null) lastTsRef.current = ts;
const dt = (ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
let next = timeRef.current + dt * speedRef.current;
if (next >= r.duration) {
next = r.duration;
playingRef.current = false;
setPlaying(false);
}
// Fire events crossed this frame (SFX later; update score for goals)
const events = r.events;
let cursor = eventCursorRef.current;
while (cursor < events.length && events[cursor]!.t <= next) {
const e = events[cursor]!;
if (e.type === "goal") {
setScore({ red: e.redScore, blue: e.blueScore });
}
cursor++;
}
eventCursorRef.current = cursor;
timeRef.current = next;
setTime(next);
} else {
lastTsRef.current = null;
}
paint();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [paint]);
const goalEvents =
replay?.events.filter((e) => e.type === "goal") ?? [];
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{!replay ? (
<label
className={[
"flex flex-1 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-16 text-center transition",
dragOver
? "border-emerald-500 bg-emerald-500/10"
: "border-zinc-600 bg-zinc-900/40 hover:border-zinc-400 hover:bg-zinc-900/70",
].join(" ")}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onPickFile(f);
}}
>
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
/>
<p className="text-base font-medium text-zinc-100">
Drop a replay JSON file here
</p>
<p className="mt-2 max-w-md text-sm text-zinc-400">
From the game server:{" "}
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
Logs/&#123;matchId&#125;.json
</code>
</p>
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
Choose file
</span>
{error ? (
<p className="mt-4 max-w-lg text-sm text-red-400">{error}</p>
) : null}
</label>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-mono text-sm text-zinc-300">
{fileName ?? `${replay.matchId}.json`}
</p>
<p className="mt-0.5 text-xs text-zinc-500">
Match {replay.matchId}
{replay.isPractice ? " · practice" : ""}
{" · "}
{replay.entities.length} entities
{" · "}
{replay.frames.length.toLocaleString("en-US")} samples
{" · "}
v{replay.version}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-sm tabular-nums">
<span className="text-[#e5484d]">{score.red}</span>
<span className="text-zinc-500"></span>
<span className="text-[#3b82f6]">{score.blue}</span>
</div>
<label className="cursor-pointer rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700">
Load other
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
/>
</label>
</div>
</div>
{error ? (
<p className="text-sm text-red-400">{error}</p>
) : null}
<div className="relative min-h-[280px] flex-1 overflow-hidden rounded-xl border border-zinc-700 bg-[#0d1117] shadow-inner">
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
aria-label="Replay field"
/>
</div>
<div className="space-y-3 rounded-xl border border-zinc-700 bg-[#161b22] p-3">
<div className="relative px-1 pt-3">
{/* Event markers */}
<div className="pointer-events-none absolute inset-x-1 top-0 h-3">
{goalEvents.map((e, i) => (
<span
key={`${e.t}-${i}`}
title={`Goal @ ${formatReplayTime(e.t)}`}
className="absolute top-0 h-2.5 w-1 -translate-x-1/2 rounded-sm"
style={{
left: `${replay.duration > 0 ? (e.t / replay.duration) * 100 : 0}%`,
backgroundColor: eventMarkerColor(e),
}}
/>
))}
</div>
<input
type="range"
min={0}
max={replay.duration || 1}
step={0.01}
value={Math.min(time, replay.duration)}
onChange={(e) => {
setPlaying(false);
seekTo(Number(e.target.value));
}}
className="w-full accent-emerald-500"
aria-label="Replay timeline"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (timeRef.current >= (replay.duration || 0)) {
seekTo(0);
}
setPlaying((p) => !p);
}}
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
>
{playing ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={() => {
setPlaying(false);
seekTo(0);
}}
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
>
Reset
</button>
<span className="ml-1 font-mono text-xs tabular-nums text-zinc-400">
{formatReplayTime(time)} / {formatReplayTime(replay.duration)}
</span>
<div className="ml-auto flex flex-wrap items-center gap-1">
<span className="mr-1 text-xs text-zinc-500">Speed</span>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={[
"rounded px-2 py-1 font-mono text-xs",
speed === s
? "bg-emerald-600/30 text-emerald-300"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
].join(" ")}
>
{s}×
</button>
))}
</div>
</div>
{goalEvents.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-t border-zinc-800 pt-2">
<span className="mr-1 self-center text-[11px] uppercase tracking-wide text-zinc-500">
Goals
</span>
{goalEvents.map((e, i) => {
const scorer =
e.team === "Red"
? "Blue"
: e.team === "Blue"
? "Red"
: "?";
return (
<button
key={`${e.t}-jump-${i}`}
type="button"
onClick={() => {
setPlaying(false);
seekTo(Math.max(0, e.t - 0.05));
}}
className="rounded border border-zinc-700 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-300 hover:border-zinc-500"
>
{formatReplayTime(e.t)} · {scorer} ({e.redScore}
{e.blueScore})
</button>
);
})}
</div>
) : null}
</div>
</>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
/** Client-side cookie storing per-admin dismissed analysis alert match ids. */
export const ANALYSIS_HIDDEN_ALERTS_COOKIE = "kk_analysis_hidden_alerts";
export type AnalysisAlertCategory = "mirror" | "notEnded";
type StoredHiddenAlerts = {
mirror: number[];
notEnded: number[];
};
const EMPTY_MIRROR: number[] = [];
const EMPTY_NOT_ENDED: number[] = [];
const MAX_IDS_PER_CATEGORY = 400;
const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 365;
export const ANALYSIS_HIDDEN_ALERTS_EVENT = "kk-analysis-hidden-alerts-change";
/** Stable empty snapshots for useSyncExternalStore / initial state. */
export const EMPTY_HIDDEN_MIRROR = EMPTY_MIRROR;
export const EMPTY_HIDDEN_NOT_ENDED = EMPTY_NOT_ENDED;
let storeCache: {
cookieValue: string;
mirror: number[];
notEnded: number[];
} | null = null;
function notifyHiddenAlertsChanged(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(ANALYSIS_HIDDEN_ALERTS_EVENT));
}
}
export function subscribeAnalysisHiddenAlerts(
onStoreChange: () => void,
): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
return () =>
window.removeEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
}
function parseIdList(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const out: number[] = [];
for (const v of raw) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1) out.push(n);
}
return [...new Set(out)].sort((a, b) => a - b);
}
function invalidateStoreCache(): void {
storeCache = null;
}
function readStored(): StoredHiddenAlerts {
if (typeof document === "undefined") {
return { mirror: EMPTY_MIRROR, notEnded: EMPTY_NOT_ENDED };
}
const match = document.cookie.match(
new RegExp(`(?:^|; )${ANALYSIS_HIDDEN_ALERTS_COOKIE}=([^;]*)`),
);
const cookieValue = match?.[1] ?? "";
if (storeCache?.cookieValue === cookieValue) {
return storeCache;
}
let mirror = EMPTY_MIRROR;
let notEnded = EMPTY_NOT_ENDED;
if (cookieValue) {
try {
const parsed = JSON.parse(decodeURIComponent(cookieValue)) as unknown;
if (parsed && typeof parsed === "object") {
const o = parsed as Record<string, unknown>;
const m = parseIdList(o.mirror);
const n = parseIdList(o.notEnded);
if (m.length > 0) mirror = m;
if (n.length > 0) notEnded = n;
}
} catch {
// ignore corrupt cookie
}
}
storeCache = { cookieValue, mirror, notEnded };
return storeCache;
}
function writeStored(data: StoredHiddenAlerts): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(data));
document.cookie = `${ANALYSIS_HIDDEN_ALERTS_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE_SEC}; SameSite=Lax`;
invalidateStoreCache();
}
function trimIds(ids: number[]): number[] {
if (ids.length <= MAX_IDS_PER_CATEGORY) return ids;
return ids.slice(ids.length - MAX_IDS_PER_CATEGORY);
}
/** Match ids the admin dismissed for this alert category (stable array reference). */
export function getHiddenAnalysisMatchIds(
category: AnalysisAlertCategory,
): number[] {
const stored = readStored();
return category === "mirror" ? stored.mirror : stored.notEnded;
}
/** Add match ids to the hide list for a category (persists in cookie). */
export function hideAnalysisMatchIds(
category: AnalysisAlertCategory,
matchIds: number[],
): void {
const stored = readStored();
const set = new Set(stored[category]);
for (const id of matchIds) {
if (Number.isInteger(id) && id >= 1) set.add(id);
}
const next = trimIds([...set].sort((a, b) => a - b));
writeStored({
mirror: category === "mirror" ? next : stored.mirror,
notEnded: category === "notEnded" ? next : stored.notEnded,
});
notifyHiddenAlertsChanged();
}
+319
View File
@@ -0,0 +1,319 @@
import {
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import {
fullPagePermissions,
normalizePagePermissions,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const SCRYPT_OPTS = {
N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
} as const;
const HASH_KEYLEN = 64;
export type AdminAccountRecord = {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
permissions: PagePermissions;
};
type AccountsFile = {
version: 1;
accounts: AdminAccountRecord[];
};
function accountsFilePath(): string {
const override = process.env.ADMIN_ACCOUNTS_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-accounts.json");
}
export function getBootstrapAdminUsername(): string {
return process.env.ADMIN_USERNAME?.trim() || "admin";
}
export function getBootstrapAdminPassword(): string | null {
const password = process.env.ADMIN_PASSWORD?.trim();
return password || null;
}
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, HASH_KEYLEN, SCRYPT_OPTS);
return `scrypt:${salt.toString("base64")}:${hash.toString("base64")}`;
}
export function verifyPassword(
password: string,
stored: string,
): boolean {
const parts = stored.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[1]!, "base64");
expected = Buffer.from(parts[2]!, "base64");
} catch {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
const actual = scryptSync(password, salt, expected.length, SCRYPT_OPTS);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
function toSessionAccount(record: AdminAccountRecord): SessionAccount {
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
}
function normalizeRecord(raw: unknown): AdminAccountRecord | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return null;
if (typeof o.username !== "string" || !o.username.trim()) return null;
if (typeof o.passwordHash !== "string" || !o.passwordHash) return null;
const isAdmin = Boolean(o.isAdmin);
const permissions = isAdmin
? fullPagePermissions()
: normalizePagePermissions(
o.permissions as Partial<
Record<string, { read?: boolean; write?: boolean }>
>,
);
return {
id: o.id,
username: o.username.trim(),
passwordHash: o.passwordHash,
isAdmin,
permissions,
};
}
async function readRawFile(): Promise<AccountsFile | null> {
const filePath = accountsFilePath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return null;
throw err;
}
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const accountsRaw = (parsed as { accounts?: unknown }).accounts;
if (!Array.isArray(accountsRaw)) return { version: 1, accounts: [] };
const accounts: AdminAccountRecord[] = [];
for (const item of accountsRaw) {
const rec = normalizeRecord(item);
if (rec) accounts.push(rec);
}
return { version: 1, accounts };
} catch {
return { version: 1, accounts: [] };
}
}
async function writeAccountsFile(accounts: AdminAccountRecord[]): Promise<void> {
const filePath = accountsFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
const payload: AccountsFile = { version: 1, accounts };
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
await rename(tmp, filePath);
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function ensureAdminBootstrapped(
accounts: AdminAccountRecord[],
): Promise<AdminAccountRecord[]> {
if (accounts.some((a) => a.isAdmin)) return accounts;
const password = getBootstrapAdminPassword();
if (!password) {
throw new Error(
"ADMIN_PASSWORD is required to bootstrap the admin account.",
);
}
const username = getBootstrapAdminUsername();
const admin: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(password),
isAdmin: true,
permissions: fullPagePermissions(),
};
// Drop any non-admin that collides with the bootstrap username.
const rest = accounts.filter(
(a) => a.username.toLowerCase() !== username.toLowerCase(),
);
const next = [admin, ...rest];
await writeAccountsFile(next);
return next;
}
export async function listAccounts(): Promise<AdminAccountRecord[]> {
const file = await readRawFile();
const accounts = await ensureAdminBootstrapped(file?.accounts ?? []);
return accounts;
}
export async function listSessionAccounts(): Promise<SessionAccount[]> {
const accounts = await listAccounts();
return accounts.map(toSessionAccount);
}
export async function findAccountByUsername(
username: string,
): Promise<AdminAccountRecord | null> {
const needle = username.trim().toLowerCase();
if (!needle) return null;
const accounts = await listAccounts();
return (
accounts.find((a) => a.username.toLowerCase() === needle) ?? null
);
}
export async function findAccountById(
id: string,
): Promise<AdminAccountRecord | null> {
const accounts = await listAccounts();
return accounts.find((a) => a.id === id) ?? null;
}
export async function verifyAccountCredentials(
username: string,
password: string,
): Promise<SessionAccount | null> {
const account = await findAccountByUsername(username);
if (!account) return null;
if (!verifyPassword(password, account.passwordHash)) return null;
return toSessionAccount(account);
}
export async function createAccount(input: {
username: string;
password: string;
permissions: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
const username = input.username.trim();
if (!username) return { ok: false, error: "missingUsername" };
if (!input.password) return { ok: false, error: "missingPassword" };
return enqueueWrite(async () => {
const accounts = await listAccounts();
if (
accounts.some((a) => a.username.toLowerCase() === username.toLowerCase())
) {
return { ok: false, error: "duplicate" };
}
const record: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(input.password),
isAdmin: false,
permissions: normalizePagePermissions(input.permissions),
};
await writeAccountsFile([...accounts, record]);
return { ok: true, account: toSessionAccount(record) };
});
}
export async function updateAccount(input: {
id: string;
username?: string;
password?: string;
permissions?: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const idx = accounts.findIndex((a) => a.id === input.id);
if (idx < 0) return { ok: false, error: "notFound" };
const current = accounts[idx]!;
let username = current.username;
if (!current.isAdmin && input.username !== undefined) {
const nextName = input.username.trim();
if (!nextName) return { ok: false, error: "missingUsername" };
if (
accounts.some(
(a) =>
a.id !== current.id &&
a.username.toLowerCase() === nextName.toLowerCase(),
)
) {
return { ok: false, error: "duplicate" };
}
username = nextName;
}
let passwordHash = current.passwordHash;
if (input.password !== undefined && input.password !== "") {
passwordHash = hashPassword(input.password);
}
let permissions = current.permissions;
if (!current.isAdmin && input.permissions !== undefined) {
permissions = normalizePagePermissions(input.permissions);
} else if (current.isAdmin) {
permissions = fullPagePermissions();
}
const next: AdminAccountRecord = {
...current,
username,
passwordHash,
permissions,
};
const updated = [...accounts];
updated[idx] = next;
await writeAccountsFile(updated);
return { ok: true, account: toSessionAccount(next) };
});
}
export async function deleteAccount(
id: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const target = accounts.find((a) => a.id === id);
if (!target) return { ok: false, error: "notFound" };
if (target.isAdmin) return { ok: false, error: "cannotDeleteAdmin" };
await writeAccountsFile(accounts.filter((a) => a.id !== id));
return { ok: true };
});
}
+140
View File
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
export type AuditLogEntry = {
id: string;
at: string;
username: string;
accountId: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
/** Short device label (e.g. "Desktop · Windows · Chrome"). */
device?: string | null;
/** Raw User-Agent (truncated), mainly for login audits. */
userAgent?: string | null;
};
export type AuditLogInput = {
username: string;
accountId?: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
device?: string | null;
userAgent?: string | null;
};
const DEFAULT_READ_LIMIT = 500;
function auditLogPath(): string {
const override = process.env.ADMIN_AUDIT_LOG_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-audit.jsonl");
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
/** Append one audit entry. Never throws to callers (best-effort). */
export async function appendAuditLog(
input: AuditLogInput,
): Promise<void> {
try {
await enqueueWrite(async () => {
const filePath = auditLogPath();
await mkdir(path.dirname(filePath), { recursive: true });
const entry: AuditLogEntry = {
id: randomUUID(),
at: new Date().toISOString(),
username: input.username,
accountId: input.accountId ?? null,
action: input.action,
summary: input.summary,
...(input.details ? { details: input.details } : {}),
...(input.ip != null ? { ip: input.ip } : {}),
...(input.device != null ? { device: input.device } : {}),
...(input.userAgent != null ? { userAgent: input.userAgent } : {}),
};
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
});
} catch (err) {
console.error("[audit-log] failed to append", err);
}
}
function parseLine(line: string): AuditLogEntry | null {
const trimmed = line.trim();
if (!trimmed) return null;
try {
const raw = JSON.parse(trimmed) as Partial<AuditLogEntry>;
if (
typeof raw.id !== "string" ||
typeof raw.at !== "string" ||
typeof raw.username !== "string" ||
typeof raw.action !== "string" ||
typeof raw.summary !== "string"
) {
return null;
}
return {
id: raw.id,
at: raw.at,
username: raw.username,
accountId:
typeof raw.accountId === "string" ? raw.accountId : null,
action: raw.action,
summary: raw.summary,
...(raw.details && typeof raw.details === "object"
? { details: raw.details as Record<string, unknown> }
: {}),
...(typeof raw.ip === "string" || raw.ip === null
? { ip: raw.ip }
: {}),
...(typeof raw.device === "string" || raw.device === null
? { device: raw.device }
: {}),
...(typeof raw.userAgent === "string" || raw.userAgent === null
? { userAgent: raw.userAgent }
: {}),
};
} catch {
return null;
}
}
/** Newest-first. Caps at `limit` entries. */
export async function readAuditLog(
limit = DEFAULT_READ_LIMIT,
): Promise<AuditLogEntry[]> {
const filePath = auditLogPath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return [];
throw err;
}
const lines = text.split("\n");
const entries: AuditLogEntry[] = [];
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseLine(lines[i]!);
if (!entry) continue;
entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Best-effort device / browser label from a User-Agent string.
* Not for security decisions display and audit only.
*/
export function parseDeviceFromUserAgent(
userAgent: string | null | undefined,
): { device: string; userAgent: string | null } {
const ua = userAgent?.trim() || null;
if (!ua) {
return { device: "Unknown device", userAgent: null };
}
const os =
/Windows NT/i.test(ua)
? "Windows"
: /Android/i.test(ua)
? "Android"
: /iPhone|iPad|iPod/i.test(ua)
? "iOS"
: /Mac OS X|Macintosh/i.test(ua)
? "macOS"
: /CrOS/i.test(ua)
? "Chrome OS"
: /Linux/i.test(ua)
? "Linux"
: null;
const browser =
/Edg\//i.test(ua)
? "Edge"
: /OPR\/|Opera/i.test(ua)
? "Opera"
: /Firefox\//i.test(ua)
? "Firefox"
: /Chrome\//i.test(ua) && !/Edg\//i.test(ua)
? "Chrome"
: /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
? "Safari"
: /curl\//i.test(ua)
? "curl"
: null;
const formFactor = /Mobile|Android.*Mobile|iPhone|iPod/i.test(ua)
? "Mobile"
: /iPad|Tablet|Android(?!.*Mobile)/i.test(ua)
? "Tablet"
: "Desktop";
const parts = [formFactor, os, browser].filter(Boolean);
return {
device: parts.length > 0 ? parts.join(" · ") : "Unknown device",
userAgent: ua.slice(0, 512),
};
}
export function getRequestUserAgent(request: Request): string | null {
const ua = request.headers.get("user-agent")?.trim();
return ua || null;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Best-effort client IP for rate limiting behind a reverse proxy.
* Falls back to a sentinel when unknown so attempts are still counted.
*/
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
+9
View File
@@ -0,0 +1,9 @@
import { verifyAccountCredentials } from "@/lib/auth/accounts-store";
import type { SessionAccount } from "@/lib/auth/permissions";
export async function verifyCredentials(
username: string,
password: string,
): Promise<SessionAccount | null> {
return verifyAccountCredentials(username, password);
}
+47
View File
@@ -0,0 +1,47 @@
import { headers } from "next/headers";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { parseDeviceFromUserAgent } from "@/lib/auth/client-device";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
import type { SessionAccount } from "@/lib/auth/permissions";
function clientIpFromHeaders(h: Headers): string {
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = h.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
/** Record that an account loaded a panel page. Best-effort; never throws. */
export async function logPageAccess(
account: SessionAccount,
page: string,
extraDetails?: Record<string, unknown>,
): Promise<void> {
try {
const h = await headers();
const ip = clientIpFromHeaders(h);
const { device, userAgent } = parseDeviceFromUserAgent(
h.get("user-agent"),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "access",
summary: `Accessed ${pageAccessLabel(page)}`,
ip,
device,
userAgent,
details: {
page,
...(extraDetails ?? {}),
},
});
} catch (err) {
console.error("[audit-log] page access failed", err);
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
const MAX_FAILURES = 5;
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 15 * 60 * 1000;
type Entry = {
failures: number;
windowStartedAt: number;
lockedUntil: number | null;
};
const attemptsByIp = new Map<string, Entry>();
function getEntry(ip: string): Entry {
const existing = attemptsByIp.get(ip);
if (existing) return existing;
const entry: Entry = {
failures: 0,
windowStartedAt: Date.now(),
lockedUntil: null,
};
attemptsByIp.set(ip, entry);
return entry;
}
function resetWindowIfExpired(entry: Entry, now: number): void {
if (now - entry.windowStartedAt >= WINDOW_MS) {
entry.failures = 0;
entry.windowStartedAt = now;
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
}
}
}
export type LoginRateLimitResult =
| { allowed: true }
| { allowed: false; retryAfterSeconds: number };
export function checkLoginRateLimit(ip: string): LoginRateLimitResult {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
if (entry.lockedUntil !== null && entry.lockedUntil > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((entry.lockedUntil - now) / 1000),
};
}
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
entry.failures = 0;
entry.windowStartedAt = now;
}
return { allowed: true };
}
export function recordFailedLogin(ip: string): void {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
entry.failures += 1;
if (entry.failures >= MAX_FAILURES) {
entry.lockedUntil = now + LOCKOUT_MS;
}
}
export function clearLoginAttempts(ip: string): void {
attemptsByIp.delete(ip);
}
export function rateLimitedLoginResponse(
request: Request,
contentType: string,
retryAfterSeconds: number,
publicRequestUrl: (request: Request, path: string) => URL,
): NextResponse {
const headers = { "Retry-After": String(retryAfterSeconds) };
if (contentType.includes("application/json")) {
return NextResponse.json(
{
error: "Too many failed login attempts. Try again later.",
retryAfterSeconds,
},
{ status: 429, headers },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=locked"),
{ headers },
);
}
+21
View File
@@ -0,0 +1,21 @@
const PAGE_LABELS: Record<string, string> = {
overview: "Overview",
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
settings: "Settings",
"founders-card": "Founders card",
"founders-card-design": "Founders card design",
"ledger-book": "Ledger book",
"match-log": "Match log",
"match-replay": "Match replay",
"matchmaker-logs": "Matchmaker logs",
"replay-viewer": "Replay viewer",
};
export function pageAccessLabel(page: string): string {
return PAGE_LABELS[page] ?? page;
}
+126
View File
@@ -0,0 +1,126 @@
export const PAGE_KEYS = [
"players",
"matches",
"analysis",
"matchmaker",
"ledger",
"logs",
"founders-card",
] as const;
export type PageKey = (typeof PAGE_KEYS)[number];
/** Pages that only support read access (no write checkbox). */
export const READ_ONLY_PAGE_KEYS = ["logs"] as const satisfies readonly PageKey[];
export type PagePermission = {
read: boolean;
write: boolean;
};
export type PagePermissions = Record<PageKey, PagePermission>;
export function emptyPagePermissions(): PagePermissions {
return {
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
"founders-card": { read: false, write: false },
};
}
export function fullPagePermissions(): PagePermissions {
return {
players: { read: true, write: true },
matches: { read: true, write: true },
analysis: { read: true, write: true },
matchmaker: { read: true, write: true },
ledger: { read: true, write: true },
logs: { read: true, write: false },
"founders-card": { read: true, write: true },
};
}
/** Normalize permissions: write implies read. Logs never grants write. */
export function normalizePagePermissions(
input: Partial<Record<PageKey, Partial<PagePermission>>> | null | undefined,
): PagePermissions {
const base = emptyPagePermissions();
for (const key of PAGE_KEYS) {
const raw = input?.[key];
if (key === "logs") {
base[key] = { read: Boolean(raw?.read), write: false };
continue;
}
const write = Boolean(raw?.write);
const read = write || Boolean(raw?.read);
base[key] = { read, write };
}
return base;
}
export type SessionAccount = {
id: string;
username: string;
isAdmin: boolean;
permissions: PagePermissions;
};
export type NavPageAccess = {
players: boolean;
matches: boolean;
analysis: boolean;
matchmaker: boolean;
ledger: boolean;
logs: boolean;
foundersCard: boolean;
};
export function navPageAccess(account: SessionAccount): NavPageAccess {
return {
players: canReadPage(account, "players"),
matches: canReadPage(account, "matches"),
analysis: canReadPage(account, "analysis"),
matchmaker: canReadPage(account, "matchmaker"),
ledger: canReadPage(account, "ledger"),
logs: canReadPage(account, "logs"),
foundersCard: canReadPage(account, "founders-card"),
};
}
export function canReadPage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
return account.permissions[page].read;
}
export function canWritePage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
if (page === "logs") return false;
return account.permissions[page].write;
}
/** Map dashboard tab (except overview) to a permission page key. */
export function tabToPageKey(
tab: string,
): PageKey | null {
if (
tab === "players" ||
tab === "matches" ||
tab === "analysis" ||
tab === "matchmaker" ||
tab === "ledger" ||
tab === "logs"
) {
return tab;
}
return null;
}
+67
View File
@@ -0,0 +1,67 @@
import { findAccountByUsername } from "@/lib/auth/accounts-store";
import {
canReadPage,
canWritePage,
fullPagePermissions,
normalizePagePermissions,
type PageKey,
type SessionAccount,
} from "@/lib/auth/permissions";
import { parseSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function getSessionAccount(): Promise<SessionAccount | null> {
const cookieStore = await cookies();
const username = parseSessionToken(
cookieStore.get(ADMIN_SESSION_COOKIE)?.value,
);
if (!username) return null;
const record = await findAccountByUsername(username);
if (!record) return null;
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
}
export async function requireSession(): Promise<SessionAccount> {
const account = await getSessionAccount();
if (!account) {
redirect("/login");
}
return account;
}
export async function requireAdmin(): Promise<SessionAccount> {
const account = await requireSession();
if (!account.isAdmin) {
redirect("/");
}
return account;
}
export async function requirePageRead(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canReadPage(account, page)) {
redirect("/");
}
return account;
}
export async function requirePageWrite(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canWritePage(account, page)) {
redirect("/");
}
return account;
}
+58
View File
@@ -0,0 +1,58 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/** Legacy cookie values from the old role-based session (invalidate on parse). */
export const LEGACY_ADMIN_SESSION_VALUE = "1";
function getSessionSecret(): string {
const explicit = process.env.ADMIN_SESSION_SECRET?.trim();
if (explicit) return explicit;
const bootstrap = process.env.ADMIN_PASSWORD?.trim();
if (bootstrap) return `kickkings-session:${bootstrap}`;
return "kickkings-dev-session-secret";
}
function signUsername(username: string): string {
return createHmac("sha256", getSessionSecret())
.update(username)
.digest("base64url");
}
/** Build cookie payload: `username.signature`. */
export function createSessionToken(username: string): string {
const u = username.trim();
return `${u}.${signUsername(u)}`;
}
/**
* Parse and verify a signed session cookie.
* Returns the username, or null if invalid / legacy.
*/
export function parseSessionToken(value: string | undefined): string | null {
if (!value) return null;
// Invalidate old role cookies immediately.
if (
value === LEGACY_ADMIN_SESSION_VALUE ||
value === "admin" ||
value === "supervisor"
) {
return null;
}
const dot = value.lastIndexOf(".");
if (dot <= 0 || dot === value.length - 1) return null;
const username = value.slice(0, dot);
const sig = value.slice(dot + 1);
if (!username || !sig) return null;
const expected = signUsername(username);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return null;
if (!timingSafeEqual(a, b)) return null;
return username;
}
/** Whether the cookie looks like a valid signed session (for middleware/proxy). */
export function hasValidSessionToken(value: string | undefined): boolean {
return parseSessionToken(value) != null;
}
+20 -4
View File
@@ -1,5 +1,7 @@
import type { NextResponse } from "next/server";
import { createSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
import { getRequestOrigin } from "@/lib/request-public-url";
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
function isHttpsRequest(request: Request): boolean {
@@ -8,10 +10,20 @@ function isHttpsRequest(request: Request): boolean {
} catch {
/* ignore */
}
const xf = request.headers.get("x-forwarded-proto");
const xf = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
if (xf === "https") return true;
if (xf === "http") return false;
return false;
const fwd = request.headers.get("forwarded");
if (fwd) {
const m = /(?:^|[;,]\s*)proto=(https?)/i.exec(fwd);
if (m?.[1] === "https") return true;
if (m?.[1] === "http") return false;
}
try {
return getRequestOrigin(request).startsWith("https://");
} catch {
return false;
}
}
export function getSessionCookieSetOptions(request: Request) {
@@ -35,10 +47,14 @@ export function getSessionCookieClearOptions(request: Request) {
}
/** Attach session cookie to a response (works reliably with redirects). */
export function applySessionCookie(res: NextResponse, request: Request) {
export function applySessionCookie(
res: NextResponse,
request: Request,
username: string,
) {
res.cookies.set(
ADMIN_SESSION_COOKIE,
"1",
createSessionToken(username),
getSessionCookieSetOptions(request),
);
}
+13
View File
@@ -0,0 +1,13 @@
export const FOUNDERS_CARD_CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "X-Founder-Id",
} as const;
export function foundersCardJsonError(message: string, status: number) {
return Response.json(
{ error: message },
{ status, headers: { ...FOUNDERS_CARD_CORS, "Cache-Control": "no-store" } },
);
}
+30
View File
@@ -0,0 +1,30 @@
import { ZipArchive } from "archiver";
import { createWriteStream } from "node:fs";
export function createZipFromDir(
pngDir: string,
zipPath: string,
onProgress: (current: number, total: number) => void,
): Promise<void> {
const output = createWriteStream(zipPath);
const archive = new ZipArchive({ zlib: { level: 6 } });
archive.on("progress", (p) => {
const total = Math.max(1, p.entries.total);
onProgress(p.entries.processed, total);
});
const done = new Promise<void>((resolve, reject) => {
output.on("close", () => resolve());
output.on("error", reject);
archive.on("error", reject);
archive.on("warning", (err) => {
if (err.code === "ENOENT") return;
reject(err);
});
});
archive.pipe(output);
archive.directory(pngDir, false);
return archive.finalize().then(() => done);
}
+118
View File
@@ -0,0 +1,118 @@
import {
fontString,
hexToRgba,
type DrawStyle,
} from "@/lib/card-gen/id-config";
export type TextMetricsBox = {
x: number;
y: number;
baselineY: number;
width: number;
height: number;
ascent: number;
descent: number;
};
type MeasureCtx = {
font: string;
textAlign: CanvasTextAlign;
textBaseline: CanvasTextBaseline;
measureText: (text: string) => TextMetrics;
fillStyle: string | CanvasGradient | CanvasPattern;
strokeStyle: string | CanvasGradient | CanvasPattern;
lineWidth: number;
lineJoin: CanvasLineJoin;
miterLimit: number;
shadowColor: string;
shadowBlur: number;
shadowOffsetX: number;
shadowOffsetY: number;
fillText: (text: string, x: number, y: number) => void;
strokeText: (text: string, x: number, y: number) => void;
};
function applyShadow(ctx: MeasureCtx, style: DrawStyle) {
if (!style.shadowEnabled) {
clearShadow(ctx);
return;
}
ctx.shadowColor = hexToRgba(style.shadowColor, style.shadowOpacity);
ctx.shadowBlur = style.shadowBlur;
ctx.shadowOffsetX = style.shadowOffsetX;
ctx.shadowOffsetY = style.shadowOffsetY;
}
function clearShadow(ctx: MeasureCtx) {
ctx.shadowColor = "rgba(0,0,0,0)";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
export function measureIdBox(
ctx: MeasureCtx,
text: string,
canvasWidth: number,
canvasHeight: number,
style: DrawStyle,
): TextMetricsBox {
ctx.font = fontString(style);
ctx.textAlign = style.align;
ctx.textBaseline = "alphabetic";
const metrics = ctx.measureText(text);
const ascent =
Number.isFinite(metrics.actualBoundingBoxAscent) &&
metrics.actualBoundingBoxAscent > 0
? metrics.actualBoundingBoxAscent
: style.fontSize * 0.8;
const descent =
Number.isFinite(metrics.actualBoundingBoxDescent) &&
metrics.actualBoundingBoxDescent > 0
? metrics.actualBoundingBoxDescent
: style.fontSize * 0.2;
const x = (style.xPercent / 100) * canvasWidth;
const y = (style.yPercent / 100) * canvasHeight;
const baselineY = y + (ascent - descent) / 2;
const width = metrics.width;
let left = x;
if (style.align === "center") left = x - width / 2;
else if (style.align === "right") left = x - width;
return {
x: left,
y: baselineY - ascent,
baselineY,
width,
height: ascent + descent,
ascent,
descent,
};
}
export function drawIdText(
ctx: MeasureCtx,
text: string,
canvasWidth: number,
canvasHeight: number,
style: DrawStyle,
): TextMetricsBox {
const box = measureIdBox(ctx, text, canvasWidth, canvasHeight, style);
const drawX = (style.xPercent / 100) * canvasWidth;
applyShadow(ctx, style);
if (style.outlineEnabled && style.outlineWidth > 0) {
ctx.lineJoin = "round";
ctx.miterLimit = 2;
ctx.lineWidth = style.outlineWidth;
ctx.strokeStyle = hexToRgba(style.outlineColor, style.outlineOpacity);
ctx.strokeText(text, drawX, box.baselineY);
}
clearShadow(ctx);
if (!style.outlineEnabled && style.shadowEnabled) {
applyShadow(ctx, style);
}
ctx.fillStyle = style.color;
ctx.fillText(text, drawX, box.baselineY);
clearShadow(ctx);
return box;
}
+115
View File
@@ -0,0 +1,115 @@
import { createAdminSupabase } from "@/lib/supabase/admin";
export const EARLY_PLAYER_EMAILS_TABLE = "early_player_emails";
export type EarlyPlayerEmailRow = {
id: number;
created_at: string;
email: string;
preset: string;
};
function asId(value: unknown): number | null {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isInteger(n) || n < 1) return null;
return n;
}
export function normalizeSignupEmail(raw: string): string | null {
const email = raw.trim().toLowerCase();
if (email.length < 3 || email.length > 254) return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
return email;
}
function parseRow(raw: unknown): EarlyPlayerEmailRow | null {
if (!raw || typeof raw !== "object") return null;
const row = raw as Record<string, unknown>;
const id = asId(row.id);
const email = typeof row.email === "string" ? row.email : "";
const preset = typeof row.preset === "string" ? row.preset : "";
const created_at =
typeof row.created_at === "string" ? row.created_at : "";
if (id == null || !email || !created_at) return null;
return { id, created_at, email, preset: preset || "founder-card-5" };
}
export async function listEarlyPlayerEmails(): Promise<
{ rows: EarlyPlayerEmailRow[] } | { error: string }
> {
const supabase = createAdminSupabase();
if (!supabase) {
return {
error:
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.",
};
}
const pageSize = 1000;
const rows: EarlyPlayerEmailRow[] = [];
let offset = 0;
for (;;) {
const { data, error } = await supabase
.from(EARLY_PLAYER_EMAILS_TABLE)
.select("id, created_at, email, preset")
.order("id", { ascending: false })
.range(offset, offset + pageSize - 1);
if (error) return { error: error.message };
const batch = (data ?? [])
.map(parseRow)
.filter((row): row is EarlyPlayerEmailRow => row != null);
rows.push(...batch);
if (!data || data.length < pageSize) break;
offset += pageSize;
}
return { rows };
}
export async function upsertEarlyPlayerEmail(
email: string,
preset: string,
): Promise<{ id: number } | { error: string; status: number }> {
const supabase = createAdminSupabase();
if (!supabase) {
return {
error:
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.",
status: 503,
};
}
const inserted = await supabase
.from(EARLY_PLAYER_EMAILS_TABLE)
.insert({ email, preset })
.select("id")
.single();
if (!inserted.error) {
const id = asId(inserted.data?.id);
if (id == null) return { error: "Could not read founder id", status: 500 };
return { id };
}
if (inserted.error.code === "23505") {
const existing = await supabase
.from(EARLY_PLAYER_EMAILS_TABLE)
.select("id")
.eq("email", email)
.single();
const id = asId(existing.data?.id);
if (id == null) {
return { error: "Email already registered", status: 409 };
}
return { id };
}
return {
error: inserted.error.message || "Could not save email",
status: 500,
};
}
+18
View File
@@ -0,0 +1,18 @@
export function extFromFileName(
name: string,
mime: string,
fallback: string,
): string {
const m = /\.([a-z0-9]+)$/i.exec(name);
if (m) return `.${m[1]!.toLowerCase()}`;
if (mime === "image/png") return ".png";
if (mime === "image/jpeg") return ".jpg";
if (mime === "image/webp") return ".webp";
if (mime === "font/otf" || mime === "application/x-font-otf") return ".otf";
if (mime === "font/ttf" || mime === "application/x-font-ttf") return ".ttf";
return fallback;
}
export function extFromFile(file: File, fallback: string): string {
return extFromFileName(file.name, file.type, fallback);
}
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { CUSTOM_FONT_FAMILY } from "@/lib/card-gen/id-config";
import { PRESET_FONTS } from "@/lib/card-gen/fonts";
export function fontsDir(): string {
return path.join(process.cwd(), "public", "fonts");
}
export function fontEntriesForFamily(
family: string,
customFontPath?: string | null,
): { path: string; family: string }[] {
if (family === CUSTOM_FONT_FAMILY) {
if (!customFontPath) return [];
return [{ path: customFontPath, family: CUSTOM_FONT_FAMILY }];
}
const preset = PRESET_FONTS.find((f) => f.family === family);
if (!preset) return [];
const dir = fontsDir();
return [
{ path: path.join(dir, preset.regularFile), family: preset.family },
{ path: path.join(dir, preset.boldFile), family: preset.family },
];
}
+35
View File
@@ -0,0 +1,35 @@
export type PresetFont = {
family: string;
regularFile: string;
boldFile: string;
};
export const PRESET_FONTS: PresetFont[] = [
{
family: "Inter",
regularFile: "Inter-Regular.ttf",
boldFile: "Inter-Bold.ttf",
},
{
family: "Roboto",
regularFile: "Roboto-Regular.ttf",
boldFile: "Roboto-Bold.ttf",
},
{
family: "Oswald",
regularFile: "Oswald-Regular.ttf",
boldFile: "Oswald-Bold.ttf",
},
{
family: "Montserrat",
regularFile: "Montserrat-Regular.ttf",
boldFile: "Montserrat-Bold.ttf",
},
{
family: "Playfair Display",
regularFile: "PlayfairDisplay-Regular.ttf",
boldFile: "PlayfairDisplay-Bold.ttf",
},
];
export const PRESET_FONT_FAMILIES = PRESET_FONTS.map((f) => f.family);
+90
View File
@@ -0,0 +1,90 @@
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import type { DrawStyle } from "@/lib/card-gen/id-config";
export type GenerateTask = {
text: string;
outPath: string;
};
export type WorkerFont = {
path: string;
family: string;
};
type WorkerMsg =
| { type: "progress" }
| { type: "error"; message: string };
export async function generatePngsInWorkers(args: {
imagePath: string;
fonts: WorkerFont[];
style: DrawStyle;
tasks: GenerateTask[];
onProgress: (current: number, total: number) => void;
}): Promise<void> {
const { imagePath, fonts, style, tasks, onProgress } = args;
if (tasks.length === 0) return;
const require = createRequire(path.join(process.cwd(), "package.json"));
const threads = require("node:worker_threads") as typeof import("node:worker_threads");
const workerCount = Math.max(
1,
Math.min(os.cpus().length || 1, 8, tasks.length),
);
const buckets: GenerateTask[][] = Array.from(
{ length: workerCount },
() => [],
);
tasks.forEach((task, i) => {
buckets[i % workerCount]!.push(task);
});
const workerPath = path.join(process.cwd(), "workers", "generate-id.cjs");
let completed = 0;
const total = tasks.length;
await Promise.all(
buckets
.filter((bucket) => bucket.length > 0)
.map(
(bucket) =>
new Promise<void>((resolve, reject) => {
const worker = new threads.Worker(workerPath, {
workerData: {
imagePath,
fonts,
style,
tasks: bucket,
},
});
let settled = false;
const fail = (err: Error) => {
if (settled) return;
settled = true;
void worker.terminate();
reject(err);
};
worker.on("message", (msg: WorkerMsg) => {
if (msg?.type === "progress") {
completed += 1;
onProgress(completed, total);
return;
}
if (msg?.type === "error") {
fail(new Error(msg.message || "Worker error"));
}
});
worker.on("error", (err) => fail(err));
worker.on("exit", (code) => {
if (settled) return;
settled = true;
if (code === 0) resolve();
else reject(new Error(`Worker exited with code ${code}`));
});
}),
),
);
}
+295
View File
@@ -0,0 +1,295 @@
export type TextAlign = "left" | "center" | "right";
export type DrawStyle = {
fontFamily: string;
fontSize: number;
color: string;
bold: boolean;
italic: boolean;
align: TextAlign;
xPercent: number;
yPercent: number;
shadowEnabled: boolean;
shadowColor: string;
shadowOpacity: number;
shadowBlur: number;
shadowOffsetX: number;
shadowOffsetY: number;
outlineEnabled: boolean;
outlineColor: string;
outlineOpacity: number;
outlineWidth: number;
};
export type GenerateConfig = DrawStyle & {
prefix: string;
suffix: string;
start: number;
pad: number;
count: number;
};
export const CUSTOM_FONT_FAMILY = "CustomUpload";
export const LIMITS = {
count: { min: 1, max: 1000 },
pad: { min: 0, max: 6 },
start: { min: 0, max: 1_000_000 },
fontSize: { min: 8, max: 400 },
xPercent: { min: 0, max: 100 },
yPercent: { min: 0, max: 100 },
opacity: { min: 0, max: 1 },
shadowBlur: { min: 0, max: 80 },
shadowOffset: { min: -200, max: 200 },
outlineWidth: { min: 0, max: 40 },
imageBytes: 10 * 1024 * 1024,
fontBytes: 5 * 1024 * 1024,
} as const;
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export const DEFAULT_CONFIG: GenerateConfig = {
prefix: "KK",
suffix: "",
start: 1,
pad: 4,
count: 1000,
fontFamily: "Inter",
fontSize: 48,
color: "#111111",
bold: false,
italic: false,
align: "center",
xPercent: 50,
yPercent: 50,
shadowEnabled: false,
shadowColor: "#000000",
shadowOpacity: 0.75,
shadowBlur: 8,
shadowOffsetX: 2,
shadowOffsetY: 2,
outlineEnabled: false,
outlineColor: "#ffffff",
outlineOpacity: 1,
outlineWidth: 3,
};
export function formatId(
prefix: string,
n: number,
pad: number,
suffix: string,
): string {
return `${prefix}${String(n).padStart(pad, "0")}${suffix}`;
}
export function sampleId(config: GenerateConfig): string {
return formatId(config.prefix, config.start, config.pad, config.suffix);
}
export function sanitizeFilename(id: string): string {
const cleaned = id.replace(/[/\\:*?"<>|]/g, "").trim();
return cleaned || "id";
}
export function isHexColor(value: string): boolean {
return HEX_RE.test(value.trim());
}
export function parseHexColor(
hex: string,
): { r: number; g: number; b: number; a: number } | null {
const raw = hex.trim();
if (!HEX_RE.test(raw)) return null;
const h = raw.slice(1);
if (h.length === 3) {
return {
r: parseInt(h[0]! + h[0], 16),
g: parseInt(h[1]! + h[1], 16),
b: parseInt(h[2]! + h[2], 16),
a: 1,
};
}
if (h.length === 6) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
}
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: parseInt(h.slice(6, 8), 16) / 255,
};
}
export function hexToRgba(hex: string, opacity: number): string {
const c = parseHexColor(hex) ?? { r: 0, g: 0, b: 0, a: 1 };
const a = Math.max(0, Math.min(1, c.a * opacity));
return `rgba(${c.r}, ${c.g}, ${c.b}, ${a})`;
}
export function fontString(style: DrawStyle): string {
const italic = style.italic ? "italic " : "";
const weight = style.bold ? 700 : 400;
return `${italic}${weight} ${style.fontSize}px "${style.fontFamily}"`;
}
function asNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return fallback;
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
function asInt(value: unknown, fallback: number, min: number, max: number): number {
return clamp(Math.trunc(asNumber(value, fallback)), min, max);
}
function asBool(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
return fallback;
}
function asAlign(value: unknown): TextAlign {
if (value === "left" || value === "center" || value === "right") return value;
return "center";
}
function asColor(value: unknown, fallback: string): string {
if (typeof value === "string" && isHexColor(value)) return value.trim();
return fallback;
}
export function parseGenerateConfig(input: unknown): GenerateConfig | null {
if (!input || typeof input !== "object") return null;
const o = input as Record<string, unknown>;
const prefix = typeof o.prefix === "string" ? o.prefix : DEFAULT_CONFIG.prefix;
const suffix = typeof o.suffix === "string" ? o.suffix : DEFAULT_CONFIG.suffix;
const fontFamily =
typeof o.fontFamily === "string" && o.fontFamily.trim()
? o.fontFamily.trim()
: DEFAULT_CONFIG.fontFamily;
return {
prefix,
suffix,
start: asInt(o.start, DEFAULT_CONFIG.start, LIMITS.start.min, LIMITS.start.max),
pad: asInt(o.pad, DEFAULT_CONFIG.pad, LIMITS.pad.min, LIMITS.pad.max),
count: asInt(o.count, DEFAULT_CONFIG.count, LIMITS.count.min, LIMITS.count.max),
fontFamily,
fontSize: asInt(
o.fontSize,
DEFAULT_CONFIG.fontSize,
LIMITS.fontSize.min,
LIMITS.fontSize.max,
),
color: asColor(o.color, DEFAULT_CONFIG.color),
bold: asBool(o.bold, DEFAULT_CONFIG.bold),
italic: asBool(o.italic, DEFAULT_CONFIG.italic),
align: asAlign(o.align),
xPercent: clamp(
asNumber(o.xPercent, DEFAULT_CONFIG.xPercent),
LIMITS.xPercent.min,
LIMITS.xPercent.max,
),
yPercent: clamp(
asNumber(o.yPercent, DEFAULT_CONFIG.yPercent),
LIMITS.yPercent.min,
LIMITS.yPercent.max,
),
shadowEnabled: asBool(o.shadowEnabled, DEFAULT_CONFIG.shadowEnabled),
shadowColor: asColor(o.shadowColor, DEFAULT_CONFIG.shadowColor),
shadowOpacity: clamp(
asNumber(o.shadowOpacity, DEFAULT_CONFIG.shadowOpacity),
LIMITS.opacity.min,
LIMITS.opacity.max,
),
shadowBlur: clamp(
asNumber(o.shadowBlur, DEFAULT_CONFIG.shadowBlur),
LIMITS.shadowBlur.min,
LIMITS.shadowBlur.max,
),
shadowOffsetX: clamp(
asNumber(o.shadowOffsetX, DEFAULT_CONFIG.shadowOffsetX),
LIMITS.shadowOffset.min,
LIMITS.shadowOffset.max,
),
shadowOffsetY: clamp(
asNumber(o.shadowOffsetY, DEFAULT_CONFIG.shadowOffsetY),
LIMITS.shadowOffset.min,
LIMITS.shadowOffset.max,
),
outlineEnabled: asBool(o.outlineEnabled, DEFAULT_CONFIG.outlineEnabled),
outlineColor: asColor(o.outlineColor, DEFAULT_CONFIG.outlineColor),
outlineOpacity: clamp(
asNumber(o.outlineOpacity, DEFAULT_CONFIG.outlineOpacity),
LIMITS.opacity.min,
LIMITS.opacity.max,
),
outlineWidth: clamp(
asNumber(o.outlineWidth, DEFAULT_CONFIG.outlineWidth),
LIMITS.outlineWidth.min,
LIMITS.outlineWidth.max,
),
};
}
export function pickDrawStyle(config: GenerateConfig): DrawStyle {
return {
fontFamily: config.fontFamily,
fontSize: config.fontSize,
color: config.color,
bold: config.bold,
italic: config.italic,
align: config.align,
xPercent: config.xPercent,
yPercent: config.yPercent,
shadowEnabled: config.shadowEnabled,
shadowColor: config.shadowColor,
shadowOpacity: config.shadowOpacity,
shadowBlur: config.shadowBlur,
shadowOffsetX: config.shadowOffsetX,
shadowOffsetY: config.shadowOffsetY,
outlineEnabled: config.outlineEnabled,
outlineColor: config.outlineColor,
outlineOpacity: config.outlineOpacity,
outlineWidth: config.outlineWidth,
};
}
export const IMAGE_MIME = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
]);
export const FONT_MIME = new Set([
"font/ttf",
"font/otf",
"font/sfnt",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/octet-stream",
]);
export function isImageFile(file: { name: string; type: string }): boolean {
if (IMAGE_MIME.has(file.type.toLowerCase())) return true;
return /\.(png|jpe?g|webp)$/i.test(file.name);
}
export function isFontFile(file: { name: string; type: string }): boolean {
if (FONT_MIME.has(file.type.toLowerCase())) return true;
return /\.(ttf|otf)$/i.test(file.name);
}
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, readdir, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
const JOB_TTL_MS = 15 * 60 * 1000;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type JobDir = {
id: string;
dir: string;
pngDir: string;
zipPath: string;
};
export function jobsRoot(): string {
return path.join(os.tmpdir(), "kk-card-jobs");
}
export function isValidJobId(id: string): boolean {
return UUID_RE.test(id);
}
export function jobPaths(id: string): JobDir {
const dir = path.join(jobsRoot(), id);
return {
id,
dir,
pngDir: path.join(dir, "png"),
zipPath: path.join(dir, "ids.zip"),
};
}
export async function sweepExpiredJobs(now = Date.now()): Promise<void> {
const root = jobsRoot();
let names: string[];
try {
names = await readdir(root);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return;
throw err;
}
await Promise.all(
names.map(async (name) => {
if (!isValidJobId(name)) {
await rm(path.join(root, name), { recursive: true, force: true });
return;
}
const dir = path.join(root, name);
try {
const st = await stat(dir);
if (now - st.mtimeMs > JOB_TTL_MS) {
await rm(dir, { recursive: true, force: true });
}
} catch {
// ignore races
}
}),
);
}
export async function createJobDir(): Promise<JobDir> {
await mkdir(jobsRoot(), { recursive: true });
await sweepExpiredJobs();
const id = randomUUID();
const job = jobPaths(id);
await mkdir(job.pngDir, { recursive: true });
return job;
}
+212
View File
@@ -0,0 +1,212 @@
import {
DEFAULT_CONFIG,
parseGenerateConfig,
type GenerateConfig,
} from "@/lib/card-gen/id-config";
const DB_NAME = "kk-founders-card";
const DB_VERSION = 1;
const KV_STORE = "kv";
const PRESET_STORE = "presets";
const DRAFT_KEY = "draft";
const ACTIVE_PRESET_KEY = "activePresetId";
export type CardDraft = {
config: GenerateConfig;
image: File | null;
font: File | null;
activePresetId: string | null;
savedAt: number;
};
export type CardPreset = {
id: string;
name: string;
config: GenerateConfig;
image: File | null;
font: File | null;
createdAt: number;
updatedAt: number;
};
function fileFromBlob(
blob: Blob | File | null | undefined,
fallbackName: string,
): File | null {
if (!blob) return null;
if (blob instanceof File) return blob;
const name =
typeof (blob as Blob & { name?: string }).name === "string" &&
(blob as Blob & { name?: string }).name
? (blob as Blob & { name: string }).name
: fallbackName;
return new File([blob], name, {
type: blob.type || "application/octet-stream",
lastModified: Date.now(),
});
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(KV_STORE)) {
db.createObjectStore(KV_STORE);
}
if (!db.objectStoreNames.contains(PRESET_STORE)) {
db.createObjectStore(PRESET_STORE, { keyPath: "id" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed"));
});
}
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
});
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB abort"));
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB error"));
});
}
function normalizePreset(raw: unknown): CardPreset | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return null;
if (typeof o.name !== "string" || !o.name.trim()) return null;
const config = parseGenerateConfig(o.config) ?? DEFAULT_CONFIG;
return {
id: o.id,
name: o.name.trim(),
config,
image: fileFromBlob(o.image as Blob | File | null, "base.png"),
font: fileFromBlob(o.font as Blob | File | null, "custom.ttf"),
createdAt: typeof o.createdAt === "number" ? o.createdAt : Date.now(),
updatedAt: typeof o.updatedAt === "number" ? o.updatedAt : Date.now(),
};
}
export async function loadDraft(): Promise<CardDraft> {
const db = await openDb();
try {
const tx = db.transaction([KV_STORE], "readonly");
const store = tx.objectStore(KV_STORE);
const [rawDraft, rawPresetId] = await Promise.all([
idbReq(store.get(DRAFT_KEY)),
idbReq(store.get(ACTIVE_PRESET_KEY)),
]);
await txDone(tx);
const draftObj =
rawDraft && typeof rawDraft === "object"
? (rawDraft as Record<string, unknown>)
: null;
const config = parseGenerateConfig(draftObj?.config) ?? DEFAULT_CONFIG;
return {
config,
image: fileFromBlob(draftObj?.image as Blob | File | null, "base.png"),
font: fileFromBlob(draftObj?.font as Blob | File | null, "custom.ttf"),
activePresetId:
typeof rawPresetId === "string" && rawPresetId ? rawPresetId : null,
savedAt: typeof draftObj?.savedAt === "number" ? draftObj.savedAt : 0,
};
} finally {
db.close();
}
}
export async function saveDraft(input: {
config: GenerateConfig;
image: File | null;
font: File | null;
activePresetId: string | null;
}): Promise<number> {
const db = await openDb();
const savedAt = Date.now();
try {
const tx = db.transaction([KV_STORE], "readwrite");
const store = tx.objectStore(KV_STORE);
store.put(
{
config: input.config,
image: input.image,
font: input.font,
savedAt,
},
DRAFT_KEY,
);
store.put(input.activePresetId, ACTIVE_PRESET_KEY);
await txDone(tx);
return savedAt;
} finally {
db.close();
}
}
export async function listPresets(): Promise<CardPreset[]> {
const db = await openDb();
try {
const tx = db.transaction([PRESET_STORE], "readonly");
const raw = await idbReq(tx.objectStore(PRESET_STORE).getAll());
await txDone(tx);
const presets = (Array.isArray(raw) ? raw : [])
.map(normalizePreset)
.filter((p): p is CardPreset => p !== null);
presets.sort((a, b) => a.createdAt - b.createdAt);
return presets;
} finally {
db.close();
}
}
export async function savePreset(input: {
id?: string;
name: string;
config: GenerateConfig;
image: File | null;
font: File | null;
}): Promise<CardPreset> {
const name = input.name.trim();
if (!name) throw new Error("Preset name is required");
const now = Date.now();
const existing = input.id
? (await listPresets()).find((p) => p.id === input.id)
: undefined;
const preset: CardPreset = {
id: existing?.id ?? input.id ?? crypto.randomUUID(),
name,
config: input.config,
image: input.image,
font: input.font,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const db = await openDb();
try {
const tx = db.transaction([PRESET_STORE], "readwrite");
tx.objectStore(PRESET_STORE).put(preset);
await txDone(tx);
return preset;
} finally {
db.close();
}
}
export async function deletePreset(id: string): Promise<void> {
const db = await openDb();
try {
const tx = db.transaction([PRESET_STORE], "readwrite");
tx.objectStore(PRESET_STORE).delete(id);
await txDone(tx);
} finally {
db.close();
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { GenerateConfig } from "@/lib/card-gen/id-config";
import { savePreset, type CardPreset } from "@/lib/card-gen/local-store";
export type ServerPresetMeta = {
id: string;
name: string;
config: GenerateConfig;
imageName: string;
hasImage: boolean;
fontName: string | null;
hasFont: boolean;
createdAt: number;
updatedAt: number;
};
async function readError(res: Response): Promise<string> {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
return body?.error || `Request failed (${res.status})`;
}
export async function fetchServerPresetList(): Promise<ServerPresetMeta[]> {
const res = await fetch("/api/founders-card/presets", { cache: "no-store" });
if (!res.ok) throw new Error(await readError(res));
const body = (await res.json()) as { presets?: ServerPresetMeta[] };
return Array.isArray(body.presets) ? body.presets : [];
}
async function fileFromEndpoint(
url: string,
fallbackName: string,
): Promise<File | null> {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
const blob = await res.blob();
if (blob.size === 0) return null;
const name =
/filename="([^"]+)"/i.exec(res.headers.get("content-disposition") ?? "")?.[1] ??
fallbackName;
return new File([blob], name, {
type: blob.type || "application/octet-stream",
});
}
export async function fetchServerPresets(): Promise<CardPreset[]> {
const list = await fetchServerPresetList();
const out: CardPreset[] = [];
for (const meta of list) {
const image = meta.hasImage
? await fileFromEndpoint(
`/api/founders-card/presets/${meta.id}/image`,
meta.imageName || "base.png",
)
: null;
const font = meta.hasFont
? await fileFromEndpoint(
`/api/founders-card/presets/${meta.id}/font`,
meta.fontName || "custom.ttf",
)
: null;
out.push({
id: meta.id,
name: meta.name,
config: meta.config,
image,
font,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
});
}
return out;
}
export async function pushPresetToServer(input: {
id?: string;
name: string;
config: GenerateConfig;
image: File | null;
font: File | null;
}): Promise<ServerPresetMeta> {
const fd = new FormData();
fd.append("name", input.name);
fd.append("config", JSON.stringify(input.config));
if (input.id) fd.append("id", input.id);
if (input.image) fd.append("image", input.image);
if (input.font) fd.append("font", input.font);
if (!input.font) fd.append("clearFont", "1");
const res = await fetch("/api/founders-card/presets", {
method: "POST",
body: fd,
});
if (!res.ok) throw new Error(await readError(res));
return (await res.json()) as ServerPresetMeta;
}
export async function deleteServerPreset(id: string): Promise<void> {
const res = await fetch(`/api/founders-card/presets/${id}`, {
method: "DELETE",
});
if (!res.ok && res.status !== 404) throw new Error(await readError(res));
}
export async function cacheServerPresetsLocally(
presets: CardPreset[],
): Promise<void> {
for (const preset of presets) {
await savePreset({
id: preset.id,
name: preset.name,
config: preset.config,
image: preset.image,
font: preset.font,
});
}
}
+242
View File
@@ -0,0 +1,242 @@
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import {
parseGenerateConfig,
type GenerateConfig,
} from "@/lib/card-gen/id-config";
import { extFromFileName } from "@/lib/card-gen/file-ext";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type PresetMeta = {
id: string;
name: string;
config: GenerateConfig;
imageFile: string;
imageName: string;
fontFile: string | null;
fontName: string | null;
createdAt: number;
updatedAt: number;
};
export type LoadedPreset = PresetMeta & {
dir: string;
imagePath: string;
fontPath: string | null;
};
export function presetsRoot(): string {
const override = process.env.FOUNDERS_CARD_PRESETS_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "founders-card-presets");
}
export function isPresetId(id: string): boolean {
return UUID_RE.test(id);
}
function presetDir(id: string): string {
return path.join(presetsRoot(), id);
}
function namesEqual(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
function parseMeta(raw: unknown, id: string): PresetMeta | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name.trim() : "";
if (!name) return null;
const config = parseGenerateConfig(o.config);
if (!config) return null;
if (typeof o.imageFile !== "string" || !o.imageFile) return null;
if (o.imageFile.includes("..") || o.imageFile.includes("/") || o.imageFile.includes("\\")) {
return null;
}
let fontFile: string | null =
typeof o.fontFile === "string" && o.fontFile ? o.fontFile : null;
if (
fontFile &&
(fontFile.includes("..") || fontFile.includes("/") || fontFile.includes("\\"))
) {
fontFile = null;
}
return {
id,
name,
config,
imageFile: o.imageFile,
imageName:
typeof o.imageName === "string" && o.imageName.trim()
? o.imageName.trim()
: "base.png",
fontFile,
fontName:
typeof o.fontName === "string" && o.fontName.trim()
? o.fontName.trim()
: null,
createdAt: typeof o.createdAt === "number" ? o.createdAt : Date.now(),
updatedAt: typeof o.updatedAt === "number" ? o.updatedAt : Date.now(),
};
}
async function readMeta(id: string): Promise<PresetMeta | null> {
if (!isPresetId(id)) return null;
try {
const text = await readFile(path.join(presetDir(id), "meta.json"), "utf8");
return parseMeta(JSON.parse(text) as unknown, id);
} catch {
return null;
}
}
async function writeMeta(meta: PresetMeta): Promise<void> {
const dir = presetDir(meta.id);
await mkdir(dir, { recursive: true });
const filePath = path.join(dir, "meta.json");
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
await rename(tmp, filePath);
}
export async function listPresetMeta(): Promise<PresetMeta[]> {
await mkdir(presetsRoot(), { recursive: true });
let names: string[];
try {
names = await readdir(presetsRoot());
} catch {
return [];
}
const out: PresetMeta[] = [];
for (const name of names) {
const meta = await readMeta(name);
if (meta) out.push(meta);
}
out.sort((a, b) => a.createdAt - b.createdAt);
return out;
}
export async function getLoadedPreset(id: string): Promise<LoadedPreset | null> {
const meta = await readMeta(id);
if (!meta) return null;
const dir = presetDir(meta.id);
const imagePath = path.join(dir, meta.imageFile);
const fontPath = meta.fontFile ? path.join(dir, meta.fontFile) : null;
return { ...meta, dir, imagePath, fontPath };
}
export async function findPresetByName(
name: string,
): Promise<LoadedPreset | null> {
const needle = name.trim();
if (!needle) return null;
const all = await listPresetMeta();
const match = all.find((p) => namesEqual(p.name, needle));
if (!match) return null;
return getLoadedPreset(match.id);
}
export async function saveStoredPreset(input: {
id?: string;
name: string;
config: GenerateConfig;
image?: { bytes: Buffer; name: string; type: string } | null;
font?: { bytes: Buffer; name: string; type: string } | null;
clearFont?: boolean;
}): Promise<PresetMeta> {
const name = input.name.trim();
if (!name) throw new Error("Preset name is required");
return enqueueWrite(async () => {
const all = await listPresetMeta();
const byId = input.id ? all.find((p) => p.id === input.id) : undefined;
const byName = all.find((p) => namesEqual(p.name, name));
const existing = byId ?? byName;
if (byId && byName && byId.id !== byName.id) {
throw new Error("A preset with that name already exists");
}
if (!existing && !input.image) {
throw new Error("Image is required");
}
const now = Date.now();
const id =
existing?.id ??
(input.id && isPresetId(input.id) ? input.id : randomUUID());
const dir = presetDir(id);
await mkdir(dir, { recursive: true });
let imageFile = existing?.imageFile ?? "";
let imageName = existing?.imageName ?? "base.png";
if (input.image) {
const ext = extFromFileName(input.image.name, input.image.type, ".png");
imageFile = `image${ext}`;
imageName = input.image.name;
const dest = path.join(dir, imageFile);
const tmp = `${dest}.${process.pid}.tmp`;
await writeFile(tmp, input.image.bytes);
await rename(tmp, dest);
if (existing?.imageFile && existing.imageFile !== imageFile) {
await rm(path.join(dir, existing.imageFile), { force: true });
}
}
if (!imageFile) throw new Error("Image is required");
let fontFile = existing?.fontFile ?? null;
let fontName = existing?.fontName ?? null;
if (input.clearFont) {
if (fontFile) await rm(path.join(dir, fontFile), { force: true });
fontFile = null;
fontName = null;
} else if (input.font) {
const ext = extFromFileName(input.font.name, input.font.type, ".ttf");
fontFile = `font${ext}`;
fontName = input.font.name;
const dest = path.join(dir, fontFile);
const tmp = `${dest}.${process.pid}.tmp`;
await writeFile(tmp, input.font.bytes);
await rename(tmp, dest);
if (existing?.fontFile && existing.fontFile !== fontFile) {
await rm(path.join(dir, existing.fontFile), { force: true });
}
}
const meta: PresetMeta = {
id,
name,
config: input.config,
imageFile,
imageName,
fontFile,
fontName,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await writeMeta(meta);
return meta;
});
}
export async function deleteStoredPreset(id: string): Promise<boolean> {
if (!isPresetId(id)) return false;
return enqueueWrite(async () => {
const existing = await readMeta(id);
if (!existing) return false;
await rm(presetDir(id), { recursive: true, force: true });
return true;
});
}
+16
View File
@@ -0,0 +1,16 @@
export type GenerateProgressEvent =
| { phase: "generate"; current: number; total: number }
| { phase: "zip"; current: number; total: number }
| { phase: "done"; id: string }
| { phase: "error"; message: string };
export function isProgressEvent(value: unknown): value is GenerateProgressEvent {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
if (o.phase === "generate" || o.phase === "zip") {
return typeof o.current === "number" && typeof o.total === "number";
}
if (o.phase === "done") return typeof o.id === "string";
if (o.phase === "error") return typeof o.message === "string";
return false;
}
+40
View File
@@ -0,0 +1,40 @@
const WINDOW_MS = 60_000;
const MAX_CARD_HITS = 60;
const MAX_SIGNUP_HITS = 10;
const cardHitsByIp = new Map<string, number[]>();
const signupHitsByIp = new Map<string, number[]>();
function checkLimit(
store: Map<string, number[]>,
ip: string,
maxHits: number,
): { allowed: true } | { allowed: false; retryAfterSeconds: number } {
const now = Date.now();
const cutoff = now - WINDOW_MS;
const prev = store.get(ip) ?? [];
const recent = prev.filter((t) => t > cutoff);
if (recent.length >= maxHits) {
const retryAfterSeconds = Math.max(
1,
Math.ceil((recent[0]! + WINDOW_MS - now) / 1000),
);
store.set(ip, recent);
return { allowed: false, retryAfterSeconds };
}
recent.push(now);
store.set(ip, recent);
return { allowed: true };
}
export function checkPublicCardRateLimit(
ip: string,
): { allowed: true } | { allowed: false; retryAfterSeconds: number } {
return checkLimit(cardHitsByIp, ip, MAX_CARD_HITS);
}
export function checkPublicSignupRateLimit(
ip: string,
): { allowed: true } | { allowed: false; retryAfterSeconds: number } {
return checkLimit(signupHitsByIp, ip, MAX_SIGNUP_HITS);
}
+27
View File
@@ -0,0 +1,27 @@
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import type { DrawStyle } from "@/lib/card-gen/id-config";
import { generatePngsInWorkers, type WorkerFont } from "@/lib/card-gen/generate-pool";
import { createJobDir } from "@/lib/card-gen/jobs";
export async function renderOnePng(args: {
imagePath: string;
fonts: WorkerFont[];
style: DrawStyle;
text: string;
}): Promise<Buffer> {
const job = await createJobDir();
try {
const outPath = path.join(job.pngDir, "card.png");
await generatePngsInWorkers({
imagePath: args.imagePath,
fonts: args.fonts,
style: args.style,
tasks: [{ text: args.text, outPath }],
onProgress: () => undefined,
});
return await readFile(outPath);
} finally {
await rm(job.dir, { recursive: true, force: true });
}
}
+91
View File
@@ -0,0 +1,91 @@
import {
CUSTOM_FONT_FAMILY,
formatId,
LIMITS,
pickDrawStyle,
sanitizeFilename,
} from "@/lib/card-gen/id-config";
import { fontEntriesForFamily } from "@/lib/card-gen/font-files";
import { PRESET_FONT_FAMILIES } from "@/lib/card-gen/fonts";
import { findPresetByName } from "@/lib/card-gen/preset-store";
import { renderOnePng } from "@/lib/card-gen/render-one";
export const DEFAULT_SIGNUP_PRESET = "founder-card-5";
export type RenderCardResult =
| { ok: true; png: Buffer; filename: string }
| { ok: false; status: number; error: string };
export async function renderFoundersCardPng(
presetName: string,
founderId: number,
): Promise<RenderCardResult> {
if (
!Number.isInteger(founderId) ||
founderId < LIMITS.start.min ||
founderId > LIMITS.start.max
) {
return {
ok: false,
status: 400,
error: `id must be between ${LIMITS.start.min} and ${LIMITS.start.max}`,
};
}
const preset = await findPresetByName(presetName);
if (!preset) return { ok: false, status: 404, error: "Preset not found" };
const config = preset.config;
if (
config.fontFamily !== CUSTOM_FONT_FAMILY &&
!PRESET_FONT_FAMILIES.includes(config.fontFamily)
) {
return { ok: false, status: 400, error: "Preset font is invalid" };
}
if (config.fontFamily === CUSTOM_FONT_FAMILY && !preset.fontPath) {
return {
ok: false,
status: 400,
error: "Preset is missing its custom font",
};
}
const fonts = fontEntriesForFamily(config.fontFamily, preset.fontPath);
if (fonts.length === 0) {
return { ok: false, status: 500, error: "Could not resolve fonts" };
}
const text = formatId(config.prefix, founderId, config.pad, config.suffix);
const filename = `${sanitizeFilename(text)}.png`;
try {
const png = await renderOnePng({
imagePath: preset.imagePath,
fonts,
style: pickDrawStyle(config),
text,
});
return { ok: true, png, filename };
} catch (err) {
return {
ok: false,
status: 500,
error: err instanceof Error ? err.message : "Generate failed",
};
}
}
export function pngResponse(
png: Buffer,
filename: string,
extraHeaders?: Record<string, string>,
): Response {
return new Response(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Content-Length": String(png.length),
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
+22
View File
@@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string {
}
return `${rc.toFixed(1)} RC`;
}
/** Maps total coin units to `users.rc` display (whole + tenths/10). */
export function coinsBigIntToRcNumber(coins: bigint): number {
let c = coins;
if (c < BigInt(0)) c = BigInt(0);
const whole = c / BigInt(4);
const tenths = c % BigInt(4);
return Number(whole) + Number(tenths) / 10;
}
/** Adds coin units to an existing RC balance (same encoding as `users.rc`). */
export function applyCoinsDeltaToRcBalance(
currentRc: number | null,
deltaCoins: bigint,
): number {
const existingCoins =
currentRc != null && Number.isFinite(currentRc)
? rcToCoins(currentRc) ?? 0
: 0;
const total = BigInt(existingCoins) + deltaCoins;
return coinsBigIntToRcNumber(total);
}

Some files were not shown because too many files have changed in this diff Show More