Compare commits

...
2 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
64 changed files with 5338 additions and 222 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"supabase": {
"enabled": true
}
}
}
+1
View File
@@ -43,4 +43,5 @@ next-env.d.ts
# admin panel accounts (passwords hashed; still keep private) # admin panel accounts (passwords hashed; still keep private)
/data/admin-accounts.json /data/admin-accounts.json
/data/admin-audit.jsonl /data/admin-audit.jsonl
/data/founders-card-presets/
/data/*.tmp /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.
+3 -1
View File
@@ -1,5 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = {}; const nextConfig: NextConfig = {
serverExternalPackages: ["@napi-rs/canvas"],
};
export default nextConfig; export default nextConfig;
+825
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,13 +3,15 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 2613", "dev": "next dev -p 2614",
"build": "next build", "build": "next build",
"start": "next start -p 2613", "start": "next start -p 2613",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.9",
"@supabase/supabase-js": "^2.102.1", "@supabase/supabase-js": "^2.102.1",
"archiver": "^8.0.0",
"next": "^16.2.10", "next": "^16.2.10",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -17,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/archiver": "^8.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
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).
+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",
},
});
}
@@ -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); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; 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;
}
+7 -1
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { logPageAccess } from "@/lib/auth/log-page-access"; import { logPageAccess } from "@/lib/auth/log-page-access";
import { navPageAccess } from "@/lib/auth/permissions";
import { requirePageRead } from "@/lib/auth/require-session"; import { requirePageRead } from "@/lib/auth/require-session";
import { import {
formatRcDecimalFromCoinsBigInt, formatRcDecimalFromCoinsBigInt,
@@ -149,7 +150,12 @@ export default async function LedgerBookPage({
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
<AdminHeader username={account.username} isAdmin={account.isAdmin} /> <AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={navPageAccess(account)}
activeTab="ledger"
/>
<main className="flex-1 space-y-6 px-6 py-8"> <main className="flex-1 space-y-6 px-6 py-8">
<div className="mx-auto max-w-[1200px] space-y-2"> <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"> <h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
+6
View File
@@ -29,6 +29,9 @@ export default async function LoginPage({ searchParams }: Props) {
id="username" id="username"
name="username" name="username"
autoComplete="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" 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 required
/> />
@@ -45,6 +48,9 @@ export default async function LoginPage({ searchParams }: Props) {
name="password" name="password"
type="password" type="password"
autoComplete="current-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" 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 required
/> />
+24 -12
View File
@@ -4,6 +4,7 @@ import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
import { import {
canReadPage, canReadPage,
canWritePage, canWritePage,
navPageAccess,
tabToPageKey, tabToPageKey,
} from "@/lib/auth/permissions"; } from "@/lib/auth/permissions";
import { requireSession } from "@/lib/auth/require-session"; import { requireSession } from "@/lib/auth/require-session";
@@ -30,6 +31,7 @@ import {
type MatchmakerLogSource, type MatchmakerLogSource,
} from "@/lib/matchmaker-log-source"; } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import { replayFileExists } from "@/lib/match-replays-server";
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger"; import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import { import {
analyzeMatchLogsForMatches, analyzeMatchLogsForMatches,
@@ -103,14 +105,7 @@ export default async function Home({
const account = await requireSession(); const account = await requireSession();
const canWritePlayers = canWritePage(account, "players"); const canWritePlayers = canWritePage(account, "players");
const canWriteLedger = canWritePage(account, "ledger"); const canWriteLedger = canWritePage(account, "ledger");
const pageAccess = { const pageAccess = navPageAccess(account);
players: canReadPage(account, "players"),
matches: canReadPage(account, "matches"),
analysis: canReadPage(account, "analysis"),
matchmaker: canReadPage(account, "matchmaker"),
ledger: canReadPage(account, "ledger"),
logs: canReadPage(account, "logs"),
};
const sp = await searchParams; const sp = await searchParams;
const tabParam = firstSearchParam(sp.tab); const tabParam = firstSearchParam(sp.tab);
@@ -202,6 +197,7 @@ export default async function Home({
let matchmakerError: string | null = null; let matchmakerError: string | null = null;
let auditEntries: AuditLogEntry[] = []; let auditEntries: AuditLogEntry[] = [];
let auditError: string | null = null; let auditError: string | null = null;
let matchIdsWithReplay: number[] = [];
if (tab === "matchmaker") { if (tab === "matchmaker") {
const mkRaw = firstSearchParam(sp.mklog); const mkRaw = firstSearchParam(sp.mklog);
matchmakerSource = parseMatchmakerLogParam(mkRaw); matchmakerSource = parseMatchmakerLogParam(mkRaw);
@@ -228,7 +224,7 @@ export default async function Home({
} else { } else {
const usersRes = await supabase const usersRes = await supabase
.from("users") .from("users")
.select("id, created_at, username, email, ip_address, cc, rc, last_logged_at") .select("id, created_at, username, email, ip_address, cc, rc, mmr, last_logged_at")
.order("id", { ascending: false }) .order("id", { ascending: false })
.limit(500); .limit(500);
@@ -307,6 +303,16 @@ export default async function Home({
statsBundle = await loadDashboardStatsBundle(supabase); 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") { if (tab === "ledger") {
const lfromRaw = firstSearchParam(sp.lfrom); const lfromRaw = firstSearchParam(sp.lfrom);
const ltoRaw = firstSearchParam(sp.lto); const ltoRaw = firstSearchParam(sp.lto);
@@ -402,7 +408,14 @@ export default async function Home({
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader username={account.username} isAdmin={account.isAdmin} /> <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 ? ( {configError ? (
<div className="px-6 pt-8"> <div className="px-6 pt-8">
<div <div
@@ -451,10 +464,9 @@ export default async function Home({
geolocationAnalytics={geolocationAnalytics} geolocationAnalytics={geolocationAnalytics}
auditEntries={auditEntries} auditEntries={auditEntries}
auditError={auditError} auditError={auditError}
pageAccess={pageAccess}
canWritePlayers={canWritePlayers} canWritePlayers={canWritePlayers}
canWriteLedger={canWriteLedger} canWriteLedger={canWriteLedger}
isAdmin={account.isAdmin} matchIdsWithReplay={matchIdsWithReplay}
/> />
{editUser ? ( {editUser ? (
<EditUserCcRcOverlay <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>
);
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth/require-session"; import { requireAdmin } from "@/lib/auth/require-session";
import { listSessionAccounts } from "@/lib/auth/accounts-store"; import { listSessionAccounts } from "@/lib/auth/accounts-store";
import { logPageAccess } from "@/lib/auth/log-page-access"; import { logPageAccess } from "@/lib/auth/log-page-access";
import { navPageAccess } from "@/lib/auth/permissions";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { AdminSettingsEditor } from "@/components/admin-settings-editor"; import { AdminSettingsEditor } from "@/components/admin-settings-editor";
import { AdminAccountsEditor } from "@/components/admin-accounts-editor"; import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
@@ -68,7 +69,12 @@ export default async function SettingsPage({
return ( return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader username={account.username} isAdmin={account.isAdmin} /> <AdminHeader
username={account.username}
isAdmin={account.isAdmin}
pageAccess={navPageAccess(account)}
activeTab="settings"
/>
<main className="flex-1 space-y-12 px-6 py-8"> <main className="flex-1 space-y-12 px-6 py-8">
<div className="mx-auto max-w-[900px]"> <div className="mx-auto max-w-[900px]">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> <h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
+5 -8
View File
@@ -7,6 +7,7 @@ import {
updateAdminAccount, updateAdminAccount,
} from "@/app/actions/account-actions"; } from "@/app/actions/account-actions";
import { import {
emptyPagePermissions,
PAGE_KEYS, PAGE_KEYS,
type PageKey, type PageKey,
type PagePermissions, type PagePermissions,
@@ -20,6 +21,7 @@ const PAGE_LABELS: Record<PageKey, string> = {
matchmaker: "Matchmaker", matchmaker: "Matchmaker",
ledger: "Ledger", ledger: "Ledger",
logs: "System logs", logs: "System logs",
"founders-card": "Founders card",
}; };
type Props = { type Props = {
@@ -254,14 +256,9 @@ function AccountEditForm({
} }
function AddAccountForm() { function AddAccountForm() {
const [permissions, setPermissions] = useState<PagePermissions>({ const [permissions, setPermissions] = useState<PagePermissions>(() =>
players: { read: false, write: false }, emptyPagePermissions(),
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 },
});
return ( return (
<form <form
+68 -163
View File
@@ -86,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 { function playerSortKey(row: LeaderboardRow): string {
const t = row.username?.trim(); const t = row.username?.trim();
@@ -94,6 +94,10 @@ function playerSortKey(row: LeaderboardRow): string {
return "\uffff"; return "\uffff";
} }
function isEscrowUsername(username: string | null): boolean {
return (username ?? "").toLowerCase().startsWith("match_escrow_");
}
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" { function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
if (key === "player" || key === "rank") return "asc"; if (key === "player" || key === "rank") return "asc";
return "desc"; return "desc";
@@ -130,6 +134,15 @@ function sortLeaderboardRows(
else cmp = a.rcBalance! - b.rcBalance!; else cmp = a.rcBalance! - b.rcBalance!;
break; 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": { case "winRate": {
const aOk = const aOk =
a.winRatePercent != null && Number.isFinite(a.winRatePercent); a.winRatePercent != null && Number.isFinite(a.winRatePercent);
@@ -241,17 +254,10 @@ type Props = {
geolocationAnalytics: GeolocationAnalyticsResult; geolocationAnalytics: GeolocationAnalyticsResult;
auditEntries: AuditLogEntry[]; auditEntries: AuditLogEntry[];
auditError: string | null; auditError: string | null;
pageAccess: {
players: boolean;
matches: boolean;
analysis: boolean;
matchmaker: boolean;
ledger: boolean;
logs: boolean;
};
canWritePlayers: boolean; canWritePlayers: boolean;
canWriteLedger: boolean; canWriteLedger: boolean;
isAdmin: boolean; /** Match IDs that have a replay JSON file on disk. */
matchIdsWithReplay: number[];
}; };
function StatCard({ function StatCard({
@@ -314,14 +320,18 @@ export function AdminDashboard({
geolocationAnalytics, geolocationAnalytics,
auditEntries, auditEntries,
auditError, auditError,
pageAccess,
canWritePlayers, canWritePlayers,
canWriteLedger, canWriteLedger,
isAdmin, matchIdsWithReplay,
}: Props) { }: Props) {
const replaySet = useMemo(
() => new Set(matchIdsWithReplay),
[matchIdsWithReplay],
);
const [hideNoWinner, setHideNoWinner] = useState(true); const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState(""); const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins"); const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("mmr");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
/** Once we know the browser offset, reload with mtz so day bounds are local. */ /** Once we know the browser offset, reload with mtz so day bounds are local. */
@@ -399,16 +409,21 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches; if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id)); return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]); }, [filteredMatches, hideNoWinner]);
const playersPool = useMemo(() => {
if (showEscrowAccounts) return users;
return users.filter((u) => !isEscrowUsername(u.username));
}, [users, showEscrowAccounts]);
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase(); const q = playersSearch.trim().toLowerCase();
if (!q) return users; if (!q) return playersPool;
return users.filter((u) => { return playersPool.filter((u) => {
const haystack = [ const haystack = [
String(u.id), String(u.id),
u.username ?? "", u.username ?? "",
u.email ?? "", u.email ?? "",
u.cc == null ? "" : String(u.cc), u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc), u.rc == null ? "" : String(u.rc),
u.mmr == null ? "" : String(u.mmr),
formatTs(u.created_at), formatTs(u.created_at),
formatTs(u.last_logged_at), formatTs(u.last_logged_at),
u.ip_address ?? "", u.ip_address ?? "",
@@ -417,7 +432,7 @@ export function AdminDashboard({
.toLowerCase(); .toLowerCase();
return haystack.includes(q); return haystack.includes(q);
}); });
}, [users, playersSearch]); }, [playersPool, playersSearch]);
useEffect(() => { useEffect(() => {
if (tab !== "players" || !highlightId) return; if (tab !== "players" || !highlightId) return;
@@ -425,14 +440,6 @@ export function AdminDashboard({
el?.scrollIntoView({ block: "center", behavior: "smooth" }); el?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [tab, highlightId]); }, [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 { function editHref(u: DbUser): string {
return buildDashboardHref({ return buildDashboardHref({
tab, tab,
@@ -442,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) => { const statusLabel = (status: number | null) => {
if (status === 2) return "Final"; if (status === 2) return "Final";
if (status === 1) return "Live"; if (status === 1) return "Live";
@@ -455,135 +458,6 @@ export function AdminDashboard({
return ( return (
<main className="flex-1 space-y-6 px-6 py-8"> <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>
{pageAccess.players ? (
<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>
) : null}
{pageAccess.matches ? (
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
className={tabClass(tab === "matches")}
scroll={false}
>
Matches (
{totalMatchesLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${matches.length.toLocaleString("en-US")}`
: null}
)
</Link>
) : null}
{pageAccess.analysis ? (
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
) : null}
{pageAccess.matchmaker ? (
<Link
href={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matchmaker")}
scroll={false}
>
Matchmaker
</Link>
) : null}
{pageAccess.ledger ? (
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
) : null}
{pageAccess.logs ? (
<Link
href={buildDashboardHref({
tab: "logs",
highlightId,
participantRaw,
})}
className={tabClass(tab === "logs")}
scroll={false}
>
System logs
</Link>
) : null}
{pageAccess.matches || pageAccess.analysis ? (
<Link
href="/replays"
className={tabClass(false)}
scroll={false}
>
Replays
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
) : null}
</div>
<div className="mx-auto w-full max-w-[1400px]"> <div className="mx-auto w-full max-w-[1400px]">
{tab === "dashboard" ? ( {tab === "dashboard" ? (
<section className="space-y-8"> <section className="space-y-8">
@@ -679,6 +553,14 @@ export function AdminDashboard({
activeDir={lbSortDir} activeDir={lbSortDir}
onActivate={onLbSort} onActivate={onLbSort}
/> />
<LeaderboardSortTh
label="MMR"
sortKey="mmr"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
align="right"
/>
<LeaderboardSortTh <LeaderboardSortTh
label="Win rate" label="Win rate"
sortKey="winRate" sortKey="winRate"
@@ -701,7 +583,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? ( {(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr> <tr>
<td <td
colSpan={6} colSpan={7}
className="px-4 py-8 text-center text-zinc-500" className="px-4 py-8 text-center text-zinc-500"
> >
No wins recorded yet (no rows with a winner). No wins recorded yet (no rows with a winner).
@@ -735,6 +617,11 @@ export function AdminDashboard({
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300"> <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)} {formatRcBalanceWithCoins(row.rcBalance)}
</td> </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"> <td className="px-4 py-2 text-right font-mono tabular-nums">
{row.winRatePercent == null {row.winRatePercent == null
? "—" ? "—"
@@ -767,12 +654,25 @@ export function AdminDashboard({
type="search" type="search"
value={playersSearch} value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)} onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)" 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" 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>
<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"> <span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length} Showing {filteredUsers.length} of {playersPool.length}
</span> </span>
</div> </div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
@@ -784,6 +684,7 @@ export function AdminDashboard({
<th className="px-4 py-3 font-medium">Email</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">CC</th>
<th className="px-4 py-3 font-medium">RC</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">Created</th>
<th className="px-4 py-3 font-medium">Last seen</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">IP</th>
@@ -794,11 +695,13 @@ export function AdminDashboard({
{filteredUsers.length === 0 ? ( {filteredUsers.length === 0 ? (
<tr> <tr>
<td <td
colSpan={9} colSpan={10}
className="px-4 py-8 text-center text-zinc-500" className="px-4 py-8 text-center text-zinc-500"
> >
{users.length === 0 {playersPool.length === 0
? "No players yet." ? users.length === 0
? "No players yet."
: "No players left after hiding escrow accounts."
: "No players match this search."} : "No players match this search."}
</td> </td>
</tr> </tr>
@@ -826,6 +729,7 @@ export function AdminDashboard({
</td> </td>
<td className="px-4 py-2">{u.cc ?? "—"}</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.rc ?? "—"}</td>
<td className="px-4 py-2">{u.mmr ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.created_at)} {formatTs(u.created_at)}
</td> </td>
@@ -1119,6 +1023,7 @@ export function AdminDashboard({
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)} prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
winnerId={m.winner_id} winnerId={m.winner_id}
hasReplay={replaySet.has(Number(m.id))}
left={{ left={{
id: redId, id: redId,
username: leftUser?.username ?? null, username: leftUser?.username ?? null,
+50 -22
View File
@@ -1,39 +1,67 @@
"use client"; "use client";
import {
AdminNavTabs,
type AdminNavTab,
} from "@/components/admin-nav-tabs";
import type { NavPageAccess } from "@/lib/auth/permissions";
type Props = { type Props = {
username?: string; username?: string;
isAdmin?: boolean; isAdmin?: boolean;
pageAccess?: NavPageAccess;
activeTab?: AdminNavTab;
playersLabel?: string;
matchesLabel?: string;
}; };
export function AdminHeader({ username, isAdmin = false }: Props) { export function AdminHeader({
username,
isAdmin = false,
pageAccess,
activeTab = "overview",
playersLabel,
matchesLabel,
}: Props) {
async function logout() { async function logout() {
await fetch("/api/auth/logout", { method: "POST" }); await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login"; window.location.href = "/login";
} }
return ( 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"> <header className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div> <div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"> <div>
Kick Kings Admin Dashboard <h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
</h1> Kick Kings Admin Dashboard
{username ? ( </h1>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400"> {username ? (
Signed in as{" "} <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-300"> Signed in as{" "}
{username} <span className="font-medium text-zinc-700 dark:text-zinc-300">
</span> {username}
{isAdmin ? " · admin" : null} </span>
</p> {isAdmin ? " · admin" : null}
) : 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> </div>
<button {pageAccess ? (
type="button" <AdminNavTabs
onClick={() => void logout()} active={activeTab}
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" pageAccess={pageAccess}
> isAdmin={isAdmin}
Log out playersLabel={playersLabel}
</button> matchesLabel={matchesLabel}
/>
) : null}
</header> </header>
); );
} }
+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>
);
}
+26 -8
View File
@@ -61,6 +61,7 @@ type Props = {
right: PlayerSide; right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */ /** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null; winnerId: number | string | null;
hasReplay: boolean;
}; };
function idsMatch( function idsMatch(
@@ -190,6 +191,7 @@ export function MatchHistoryBattleCard({
left, left,
right, right,
winnerId, winnerId,
hasReplay,
}: Props) { }: Props) {
const entryLine = entryCombinedPerPlayerTimesTwoLine( const entryLine = entryCombinedPerPlayerTimesTwoLine(
entryFeeCoins, entryFeeCoins,
@@ -259,14 +261,30 @@ export function MatchHistoryBattleCard({
Entry fee {entryFeeLine} Entry fee {entryFeeLine}
</span> </span>
</div> </div>
<Link <div className="flex shrink-0 flex-wrap items-center gap-2">
href={`/match-logs/${matchId}`} {hasReplay ? (
target="_blank" <Link
rel="noopener noreferrer" href={`/replays/${matchId}`}
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" target="_blank"
> rel="noopener noreferrer"
Show logs 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"
</Link> >
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> </div>
</article> </article>
); );
+6 -4
View File
@@ -353,13 +353,15 @@ export function ReplayViewer({
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const cutsRef = useRef<number[]>([]); const cutsRef = useRef<number[]>(
initialReplay ? discontinuityTimes(initialReplay.events) : [],
);
const timeRef = useRef(0); const timeRef = useRef(0);
const playingRef = useRef(false); const playingRef = useRef(false);
const speedRef = useRef(1); const speedRef = useRef(1);
const lastTsRef = useRef<number | null>(null); const lastTsRef = useRef<number | null>(null);
const eventCursorRef = useRef(0); const eventCursorRef = useRef(0);
const replayRef = useRef<ReplayFile | null>(null); const replayRef = useRef<ReplayFile | null>(initialReplay);
useEffect(() => { useEffect(() => {
timeRef.current = time; timeRef.current = time;
@@ -532,7 +534,7 @@ export function ReplayViewer({
<p className="mt-2 max-w-md text-sm text-zinc-400"> <p className="mt-2 max-w-md text-sm text-zinc-400">
From the game server:{" "} From the game server:{" "}
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300"> <code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
Logs/&#123;matchId&#125;_replay.json Logs/&#123;matchId&#125;.json
</code> </code>
</p> </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"> <span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
@@ -547,7 +549,7 @@ export function ReplayViewer({
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-mono text-sm text-zinc-300"> <p className="truncate font-mono text-sm text-zinc-300">
{fileName ?? `match_${replay.matchId}_replay.json`} {fileName ?? `${replay.matchId}.json`}
</p> </p>
<p className="mt-0.5 text-xs text-zinc-500"> <p className="mt-0.5 text-xs text-zinc-500">
Match {replay.matchId} Match {replay.matchId}
+3
View File
@@ -7,8 +7,11 @@ const PAGE_LABELS: Record<string, string> = {
ledger: "Ledger", ledger: "Ledger",
logs: "System logs", logs: "System logs",
settings: "Settings", settings: "Settings",
"founders-card": "Founders card",
"founders-card-design": "Founders card design",
"ledger-book": "Ledger book", "ledger-book": "Ledger book",
"match-log": "Match log", "match-log": "Match log",
"match-replay": "Match replay",
"matchmaker-logs": "Matchmaker logs", "matchmaker-logs": "Matchmaker logs",
"replay-viewer": "Replay viewer", "replay-viewer": "Replay viewer",
}; };
+25
View File
@@ -5,6 +5,7 @@ export const PAGE_KEYS = [
"matchmaker", "matchmaker",
"ledger", "ledger",
"logs", "logs",
"founders-card",
] as const; ] as const;
export type PageKey = (typeof PAGE_KEYS)[number]; export type PageKey = (typeof PAGE_KEYS)[number];
@@ -27,6 +28,7 @@ export function emptyPagePermissions(): PagePermissions {
matchmaker: { read: false, write: false }, matchmaker: { read: false, write: false },
ledger: { read: false, write: false }, ledger: { read: false, write: false },
logs: { read: false, write: false }, logs: { read: false, write: false },
"founders-card": { read: false, write: false },
}; };
} }
@@ -38,6 +40,7 @@ export function fullPagePermissions(): PagePermissions {
matchmaker: { read: true, write: true }, matchmaker: { read: true, write: true },
ledger: { read: true, write: true }, ledger: { read: true, write: true },
logs: { read: true, write: false }, logs: { read: true, write: false },
"founders-card": { read: true, write: true },
}; };
} }
@@ -66,6 +69,28 @@ export type SessionAccount = {
permissions: PagePermissions; 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( export function canReadPage(
account: SessionAccount, account: SessionAccount,
page: PageKey, page: PageKey,
+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,
},
});
}
+9 -1
View File
@@ -18,6 +18,7 @@ export type LeaderboardRow = {
/** Matches with `winner_id` equal to this user (same as player card). */ /** Matches with `winner_id` equal to this user (same as player card). */
matchesWon: number; matchesWon: number;
rcBalance: number | null; rcBalance: number | null;
mmr: number | null;
winRatePercent: number | null; winRatePercent: number | null;
/** 1 = most wins in this snapshot (stable order for the “#” column sort). */ /** 1 = most wins in this snapshot (stable order for the “#” column sort). */
winsLeaderboardRank: number; winsLeaderboardRank: number;
@@ -106,7 +107,7 @@ async function fetchTopPlayersByMatchWins(
); );
const [usersRes, ...playedRes] = await Promise.all([ const [usersRes, ...playedRes] = await Promise.all([
supabase.from("users").select("id, username, email, rc").in("id", ids), supabase.from("users").select("id, username, email, rc, mmr").in("id", ids),
...playedPromises, ...playedPromises,
]); ]);
@@ -117,6 +118,7 @@ async function fetchTopPlayersByMatchWins(
const nameById = new Map<number, string | null>(); const nameById = new Map<number, string | null>();
const emailById = new Map<number, string | null>(); const emailById = new Map<number, string | null>();
const rcById = new Map<number, number | null>(); const rcById = new Map<number, number | null>();
const mmrById = new Map<number, number | null>();
for (const u of usersRes.data ?? []) { for (const u of usersRes.data ?? []) {
const uid = u.id as number; const uid = u.id as number;
nameById.set(uid, (u.username as string | null) ?? null); nameById.set(uid, (u.username as string | null) ?? null);
@@ -126,6 +128,11 @@ async function fetchTopPlayersByMatchWins(
uid, uid,
rawRc != null && Number.isFinite(rawRc) ? rawRc : null, rawRc != null && Number.isFinite(rawRc) ? rawRc : null,
); );
const rawMmr = u.mmr as number | null;
mmrById.set(
uid,
rawMmr != null && Number.isFinite(rawMmr) ? rawMmr : null,
);
} }
const playedErrs: string[] = []; const playedErrs: string[] = [];
@@ -152,6 +159,7 @@ async function fetchTopPlayersByMatchWins(
email: emailById.get(userId) ?? null, email: emailById.get(userId) ?? null,
matchesWon, matchesWon,
rcBalance: rcById.get(userId) ?? null, rcBalance: rcById.get(userId) ?? null,
mmr: mmrById.get(userId) ?? null,
winRatePercent, winRatePercent,
winsLeaderboardRank: index + 1, winsLeaderboardRank: index + 1,
}; };
+107
View File
@@ -0,0 +1,107 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parseReplayJsonText } from "@/lib/replay-parse";
import type { ReplayFile } from "@/types/replay";
/** Replay JSON can be several MB; sample match 534 is ~4.6 MB. */
const MAX_REPLAY_BYTES = 32 * 1024 * 1024;
export type ReadMatchReplayResult =
| { ok: true; replay: ReplayFile; fileName: string }
| {
ok: false;
status: 400 | 401 | 404 | 413 | 500 | 503;
message: string;
};
/** Fast existence check — does not parse the file. */
export async function replayFileExists(matchId: number): Promise<boolean> {
const dirRaw = process.env.MATCH_LOGS_DIR?.trim();
if (!dirRaw) return false;
const base = path.resolve(dirRaw);
const filePath = path.resolve(base, `${matchId}.json`);
if (!filePath.startsWith(base + path.sep)) return false;
try {
const st = await fs.stat(filePath);
return st.isFile();
} catch {
return false;
}
}
export async function readMatchReplayFile(
matchId: number,
): Promise<ReadMatchReplayResult> {
const dirRaw = process.env.MATCH_LOGS_DIR?.trim();
if (!dirRaw) {
return {
ok: false,
status: 503,
message:
"Match logs directory is not configured (set MATCH_LOGS_DIR on the server).",
};
}
const base = path.resolve(dirRaw);
const fileName = `${matchId}.json`;
const filePath = path.resolve(base, fileName);
if (!filePath.startsWith(base + path.sep)) {
return { ok: false, status: 500, message: "Invalid replay path." };
}
let st: Awaited<ReturnType<typeof fs.stat>>;
try {
st = await fs.stat(filePath);
} catch (e: unknown) {
const code =
e && typeof e === "object" && "code" in e
? String((e as { code: unknown }).code)
: "";
if (code === "ENOENT") {
return {
ok: false,
status: 404,
message: "No replay file for this match.",
};
}
return {
ok: false,
status: 500,
message: "Could not access replay file.",
};
}
if (!st.isFile()) {
return { ok: false, status: 404, message: "Replay path is not a file." };
}
if (st.size > MAX_REPLAY_BYTES) {
return {
ok: false,
status: 413,
message: `Replay file is too large (max ${MAX_REPLAY_BYTES} bytes).`,
};
}
let text: string;
try {
const buf = await fs.readFile(filePath);
text = buf.toString("utf8");
} catch {
return { ok: false, status: 500, message: "Could not read replay file." };
}
try {
const replay = parseReplayJsonText(text);
return { ok: true, replay, fileName };
} catch (e) {
return {
ok: false,
status: 500,
message:
e instanceof Error
? `Invalid replay JSON: ${e.message}`
: "Invalid replay JSON.",
};
}
}
+9
View File
@@ -8,6 +8,7 @@ export type DbUser = {
ip_address: string | null; ip_address: string | null;
cc: number | null; cc: number | null;
rc: number | null; rc: number | null;
mmr: number;
last_logged_at: string | null; last_logged_at: string | null;
}; };
@@ -55,6 +56,14 @@ export type DbTransaction = {
match_id: number | null; match_id: number | null;
}; };
/** Mirrors `public.early_player_emails` (see schemas/early_player_emails.md). */
export type DbEarlyPlayerEmail = {
id: number;
created_at: string;
email: string;
preset: string;
};
/** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */ /** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */
export type DbPingReport = { export type DbPingReport = {
id: number; id: number;
+128
View File
@@ -0,0 +1,128 @@
"use strict";
const { parentPort, workerData } = require("node:worker_threads");
const { writeFile } = require("node:fs/promises");
const { createCanvas, loadImage, GlobalFonts } = require("@napi-rs/canvas");
function parseHexColor(hex) {
const raw = String(hex || "").trim();
const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(raw);
if (!m) return { r: 0, g: 0, b: 0, a: 1 };
const h = m[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,
};
}
function hexToRgba(hex, opacity) {
const c = parseHexColor(hex);
const a = Math.max(0, Math.min(1, c.a * opacity));
return `rgba(${c.r}, ${c.g}, ${c.b}, ${a})`;
}
function fontString(style) {
const italic = style.italic ? "italic " : "";
const weight = style.bold ? 700 : 400;
return `${italic}${weight} ${style.fontSize}px "${style.fontFamily}"`;
}
function applyShadow(ctx, style) {
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) {
ctx.shadowColor = "rgba(0,0,0,0)";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
function drawIdText(ctx, text, canvasWidth, canvasHeight, style) {
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;
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, x, baselineY);
}
clearShadow(ctx);
if (!style.outlineEnabled && style.shadowEnabled) {
applyShadow(ctx, style);
}
ctx.fillStyle = style.color;
ctx.fillText(text, x, baselineY);
clearShadow(ctx);
}
async function run() {
const { imagePath, fonts, style, tasks } = workerData;
for (const font of fonts || []) {
GlobalFonts.registerFromPath(font.path, font.family);
}
const img = await loadImage(imagePath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext("2d");
for (const task of tasks) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
drawIdText(ctx, task.text, canvas.width, canvas.height, style);
const png = await canvas.encode("png");
await writeFile(task.outPath, png);
parentPort.postMessage({ type: "progress" });
}
}
run().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
try {
parentPort.postMessage({ type: "error", message });
} catch {
// ignore
}
process.exitCode = 1;
});