diff --git a/.gitignore b/.gitignore index a3813a4..c57aee7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ next-env.d.ts # admin panel accounts (passwords hashed; still keep private) /data/admin-accounts.json /data/admin-audit.jsonl +/data/founders-card-presets/ /data/*.tmp diff --git a/KK_cardgen_INTEGRATION.md b/KK_cardgen_INTEGRATION.md new file mode 100644 index 0000000..90c2dc4 --- /dev/null +++ b/KK_cardgen_INTEGRATION.md @@ -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 0–6, count 1–1000 (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//` 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 +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 app’s 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 1–1000, pad 0–6, start 0–1_000_000, fontSize 8–400, 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":""} +{"phase":"error","message":"..."} +``` + +Pipeline: + +1. `createJobDir()` → `{ id, dir, pngDir, zipPath }` under `tmpdir()/kk-card-jobs//` +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 `Download ids.zip` +- 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. diff --git a/next.config.ts b/next.config.ts index cb651cd..80435c2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + serverExternalPackages: ["@napi-rs/canvas"], +}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ad3f680..77fc98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "kickkings_admin", "version": "0.1.0", "dependencies": { + "@napi-rs/canvas": "^1.0.9", "@supabase/supabase-js": "^2.102.1", + "archiver": "^8.0.0", "next": "^16.2.10", "react": "19.2.4", "react-dom": "19.2.4", @@ -16,6 +18,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/archiver": "^8.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1024,6 +1027,270 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz", + "integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.9", + "@napi-rs/canvas-darwin-arm64": "1.0.9", + "@napi-rs/canvas-darwin-x64": "1.0.9", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", + "@napi-rs/canvas-linux-arm64-musl": "1.0.9", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-musl": "1.0.9", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", + "@napi-rs/canvas-win32-x64-msvc": "1.0.9" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", + "integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", + "integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", + "integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", + "integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", + "integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", + "integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", + "integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", + "integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", + "integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", + "integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", + "integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1613,6 +1880,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/readdir-glob": "*" + } + }, "node_modules/@types/d3-color": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz", @@ -1728,6 +2006,16 @@ "@types/react": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2301,6 +2589,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2357,6 +2657,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2541,6 +2861,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2587,6 +2913,20 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2594,6 +2934,106 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", @@ -2664,6 +3104,39 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2793,6 +3266,22 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2807,6 +3296,37 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3723,6 +4243,33 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3730,6 +4277,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -4178,6 +4731,26 @@ "node": ">=20.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4215,6 +4788,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4554,6 +5133,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -4697,6 +5288,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4815,6 +5407,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5117,6 +5757,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5329,10 +5970,20 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5626,10 +6277,26 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5693,6 +6360,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/react-simple-maps": { @@ -5712,6 +6380,73 @@ "react-dom": "^16.8.0 || 17.x || 18.x" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5855,6 +6590,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6142,6 +6897,26 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6348,6 +7123,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6698,6 +7503,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6854,6 +7665,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 14bc71a..c4f8d8b 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,15 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 2613", + "dev": "next dev -p 2614", "build": "next build", "start": "next start -p 2613", "lint": "eslint" }, "dependencies": { + "@napi-rs/canvas": "^1.0.9", "@supabase/supabase-js": "^2.102.1", + "archiver": "^8.0.0", "next": "^16.2.10", "react": "19.2.4", "react-dom": "19.2.4", @@ -17,6 +19,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/archiver": "^8.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/fonts/Inter-Bold.ttf b/public/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..bd74151 Binary files /dev/null and b/public/fonts/Inter-Bold.ttf differ diff --git a/public/fonts/Inter-Regular.ttf b/public/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..3e4cc80 Binary files /dev/null and b/public/fonts/Inter-Regular.ttf differ diff --git a/public/fonts/Montserrat-Bold.ttf b/public/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..dffac27 Binary files /dev/null and b/public/fonts/Montserrat-Bold.ttf differ diff --git a/public/fonts/Montserrat-Regular.ttf b/public/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..b48180c Binary files /dev/null and b/public/fonts/Montserrat-Regular.ttf differ diff --git a/public/fonts/Oswald-Bold.ttf b/public/fonts/Oswald-Bold.ttf new file mode 100644 index 0000000..ace4040 Binary files /dev/null and b/public/fonts/Oswald-Bold.ttf differ diff --git a/public/fonts/Oswald-Regular.ttf b/public/fonts/Oswald-Regular.ttf new file mode 100644 index 0000000..87095cc Binary files /dev/null and b/public/fonts/Oswald-Regular.ttf differ diff --git a/public/fonts/PlayfairDisplay-Bold.ttf b/public/fonts/PlayfairDisplay-Bold.ttf new file mode 100644 index 0000000..da4399c Binary files /dev/null and b/public/fonts/PlayfairDisplay-Bold.ttf differ diff --git a/public/fonts/PlayfairDisplay-Regular.ttf b/public/fonts/PlayfairDisplay-Regular.ttf new file mode 100644 index 0000000..2fc72b0 Binary files /dev/null and b/public/fonts/PlayfairDisplay-Regular.ttf differ diff --git a/public/fonts/Roboto-Bold.ttf b/public/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..925694f Binary files /dev/null and b/public/fonts/Roboto-Bold.ttf differ diff --git a/public/fonts/Roboto-Regular.ttf b/public/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..5c80f57 Binary files /dev/null and b/public/fonts/Roboto-Regular.ttf differ diff --git a/schemas/early_player_emails.md b/schemas/early_player_emails.md new file mode 100644 index 0000000..e48460e --- /dev/null +++ b/schemas/early_player_emails.md @@ -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). diff --git a/src/app/api/download/[jobId]/route.ts b/src/app/api/download/[jobId]/route.ts new file mode 100644 index 0000000..8afe309 --- /dev/null +++ b/src/app/api/download/[jobId]/route.ts @@ -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; + return new Response(webStream, { + headers: { + "Content-Type": "application/zip", + "Content-Length": String(size), + "Content-Disposition": 'attachment; filename="ids.zip"', + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/founders-card/presets/[presetId]/font/route.ts b/src/app/api/founders-card/presets/[presetId]/font/route.ts new file mode 100644 index 0000000..5652229 --- /dev/null +++ b/src/app/api/founders-card/presets/[presetId]/font/route.ts @@ -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; + 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", + }, + }); +} diff --git a/src/app/api/founders-card/presets/[presetId]/image/route.ts b/src/app/api/founders-card/presets/[presetId]/image/route.ts new file mode 100644 index 0000000..cf78341 --- /dev/null +++ b/src/app/api/founders-card/presets/[presetId]/image/route.ts @@ -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; + 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", + }, + }); +} diff --git a/src/app/api/founders-card/presets/[presetId]/route.ts b/src/app/api/founders-card/presets/[presetId]/route.ts new file mode 100644 index 0000000..a8c69dd --- /dev/null +++ b/src/app/api/founders-card/presets/[presetId]/route.ts @@ -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 }); +} diff --git a/src/app/api/founders-card/presets/route.ts b/src/app/api/founders-card/presets/route.ts new file mode 100644 index 0000000..a884fd6 --- /dev/null +++ b/src/app/api/founders-card/presets/route.ts @@ -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); + } +} diff --git a/src/app/api/founders-card/route.ts b/src/app/api/founders-card/route.ts new file mode 100644 index 0000000..60a011d --- /dev/null +++ b/src/app/api/founders-card/route.ts @@ -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)), + }); +} diff --git a/src/app/api/founders-card/signup/route.ts b/src/app/api/founders-card/signup/route.ts new file mode 100644 index 0000000..73c6fde --- /dev/null +++ b/src/app/api/founders-card/signup/route.ts @@ -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), + }); +} diff --git a/src/app/api/founders-card/signups/route.ts b/src/app/api/founders-card/signups/route.ts new file mode 100644 index 0000000..35f18e8 --- /dev/null +++ b/src/app/api/founders-card/signups/route.ts @@ -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 }); +} diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts new file mode 100644 index 0000000..723440f --- /dev/null +++ b/src/app/api/generate/route.ts @@ -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", + }, + }); +} diff --git a/src/app/founders-card-design/early-emails-list.tsx b/src/app/founders-card-design/early-emails-list.tsx new file mode 100644 index 0000000..75576e3 --- /dev/null +++ b/src/app/founders-card-design/early-emails-list.tsx @@ -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 ( + + ); +} diff --git a/src/app/founders-card-design/editor.tsx b/src/app/founders-card-design/editor.tsx new file mode 100644 index 0000000..961ac6a --- /dev/null +++ b/src/app/founders-card-design/editor.tsx @@ -0,0 +1,1135 @@ +"use client"; + +import Link from "next/link"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type DragEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { drawIdText, type TextMetricsBox } from "@/lib/card-gen/draw-id"; +import { PRESET_FONT_FAMILIES } from "@/lib/card-gen/fonts"; +import { + CUSTOM_FONT_FAMILY, + DEFAULT_CONFIG, + isFontFile, + isImageFile, + LIMITS, + sampleId, + type GenerateConfig, + type TextAlign, +} from "@/lib/card-gen/id-config"; +import { + deletePreset, + listPresets, + loadDraft, + saveDraft, + savePreset, + type CardPreset, +} from "@/lib/card-gen/local-store"; +import { + cacheServerPresetsLocally, + deleteServerPreset, + fetchServerPresets, + pushPresetToServer, +} from "@/lib/card-gen/preset-api"; +import { + isProgressEvent, + type GenerateProgressEvent, +} from "@/lib/card-gen/progress"; +import type { EarlyPlayerEmailRow } from "@/lib/card-gen/early-player-emails"; +import { EarlyEmailsList } from "./early-emails-list"; + +type Props = { + canGenerate: boolean; + earlyEmails: EarlyPlayerEmailRow[]; + earlyEmailsError: string | null; +}; + +type BusyState = + | { kind: "idle" } + | { kind: "generate"; current: number; total: number } + | { kind: "zip"; current: number; total: number }; + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function eventToPercent( + clientX: number, + clientY: number, + canvas: HTMLCanvasElement, +): { xPercent: number; yPercent: number } { + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * canvas.width; + const y = ((clientY - rect.top) / rect.height) * canvas.height; + return { + xPercent: clamp((x / canvas.width) * 100, 0, 100), + yPercent: clamp((y / canvas.height) * 100, 0, 100), + }; +} + +function hitBox( + clientX: number, + clientY: number, + canvas: HTMLCanvasElement, + box: TextMetricsBox | null, +): boolean { + if (!box) return false; + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * canvas.width; + const y = ((clientY - rect.top) / rect.height) * canvas.height; + const pad = 12; + return ( + x >= box.x - pad && + x <= box.x + box.width + pad && + y >= box.y - pad && + y <= box.y + box.height + pad + ); +} + +async function readNdjson( + response: Response, + onEvent: (ev: GenerateProgressEvent) => void, +): Promise { + if (!response.body) throw new Error("No response body"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl = buf.indexOf("\n"); + while (nl >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (line) { + const parsed: unknown = JSON.parse(line); + if (isProgressEvent(parsed)) onEvent(parsed); + } + nl = buf.indexOf("\n"); + } + } + const tail = buf.trim(); + if (tail) { + const parsed: unknown = JSON.parse(tail); + if (isProgressEvent(parsed)) onEvent(parsed); + } +} + +function Field({ + label, + htmlFor, + children, +}: { + label: string; + htmlFor?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +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"; +const checkClass = + "size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"; + +export function FoundersCardEditor({ + canGenerate, + earlyEmails, + earlyEmailsError, +}: Props) { + const canvasRef = useRef(null); + const imageRef = useRef(null); + const boxRef = useRef(null); + const customFaceRef = useRef(null); + const customUrlRef = useRef(null); + const imageUrlRef = useRef(null); + const dragModeRef = useRef<"none" | "move" | "place">("none"); + const saveTimerRef = useRef(null); + + const [ready, setReady] = useState(false); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [imageFile, setImageFile] = useState(null); + const [fontFile, setFontFile] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [fontReadyTick, setFontReadyTick] = useState(0); + const [presets, setPresets] = useState([]); + const [activePresetId, setActivePresetId] = useState(null); + const [presetName, setPresetName] = useState(""); + const [savedAt, setSavedAt] = useState(null); + const [saveError, setSaveError] = useState(null); + const [dropActive, setDropActive] = useState(false); + const [busy, setBusy] = useState({ kind: "idle" }); + const [downloadHref, setDownloadHref] = useState(null); + const [error, setError] = useState(null); + + const patch = useCallback((partial: Partial) => { + setConfig((prev) => ({ ...prev, ...partial })); + }, []); + + const takeImage = useCallback((file: File | null) => { + if (!file) { + setImageFile(null); + setImageLoaded(false); + return; + } + if (!isImageFile(file)) { + setError("Image must be PNG, JPG, or WebP"); + return; + } + if (file.size > LIMITS.imageBytes) { + setError("Image must be 10MB or smaller"); + return; + } + setError(null); + setImageFile(file); + setImageLoaded(false); + }, []); + + const takeFont = useCallback((file: File | null) => { + if (!file) { + setFontFile(null); + patch({ fontFamily: "Inter" }); + return; + } + if (!isFontFile(file)) { + setError("Font must be TTF or OTF"); + return; + } + if (file.size > LIMITS.fontBytes) { + setError("Font must be 5MB or smaller"); + return; + } + setError(null); + setFontFile(file); + patch({ fontFamily: CUSTOM_FONT_FAMILY }); + }, [patch]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const [draft, stored] = await Promise.all([loadDraft(), listPresets()]); + if (cancelled) return; + let presets = stored; + try { + const remote = await fetchServerPresets(); + if (cancelled) return; + if (remote.length > 0) { + await cacheServerPresetsLocally(remote); + const byId = new Map(stored.map((p) => [p.id, p])); + for (const p of remote) byId.set(p.id, p); + presets = [...byId.values()].sort( + (a, b) => a.createdAt - b.createdAt, + ); + } + } catch { + // Keep locally cached presets if the server is unreachable. + } + if (cancelled) return; + setConfig(draft.config); + setImageFile(draft.image); + setFontFile(draft.font); + setActivePresetId(draft.activePresetId); + setSavedAt(draft.savedAt || null); + setPresets(presets); + const active = presets.find((p) => p.id === draft.activePresetId); + if (active) setPresetName(active.name); + } catch (err) { + if (!cancelled) { + setSaveError( + err instanceof Error ? err.message : "Could not load saved config", + ); + } + } finally { + if (!cancelled) setReady(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!ready) return; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => { + void (async () => { + try { + const at = await saveDraft({ + config, + image: imageFile, + font: fontFile, + activePresetId, + }); + setSavedAt(at); + setSaveError(null); + } catch (err) { + setSaveError( + err instanceof Error ? err.message : "Could not save locally", + ); + } + })(); + }, 250); + return () => { + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + }; + }, [ready, config, imageFile, fontFile, activePresetId]); + + useEffect(() => { + if (imageUrlRef.current) { + URL.revokeObjectURL(imageUrlRef.current); + imageUrlRef.current = null; + } + imageRef.current = null; + setImageLoaded(false); + if (!imageFile) { + return; + } + const url = URL.createObjectURL(imageFile); + imageUrlRef.current = url; + const img = new Image(); + img.onload = () => { + imageRef.current = img; + setImageLoaded(true); + }; + img.onerror = () => { + setError("Could not read the base image"); + imageRef.current = null; + }; + img.src = url; + return () => { + if (imageUrlRef.current) { + URL.revokeObjectURL(imageUrlRef.current); + imageUrlRef.current = null; + } + }; + }, [imageFile]); + + useEffect(() => { + let cancelled = false; + const prevFace = customFaceRef.current; + const prevUrl = customUrlRef.current; + if (prevFace) { + document.fonts.delete(prevFace); + customFaceRef.current = null; + } + if (prevUrl) { + URL.revokeObjectURL(prevUrl); + customUrlRef.current = null; + } + if (!fontFile) { + setFontReadyTick((n) => n + 1); + return; + } + const url = URL.createObjectURL(fontFile); + customUrlRef.current = url; + const face = new FontFace(CUSTOM_FONT_FAMILY, `url(${url})`); + customFaceRef.current = face; + void face + .load() + .then((loaded) => { + if (cancelled) return; + document.fonts.add(loaded); + setFontReadyTick((n) => n + 1); + }) + .catch(() => { + if (!cancelled) setError("Could not load the custom font"); + }); + return () => { + cancelled = true; + }; + }, [fontFile]); + + const text = useMemo(() => sampleId(config), [config]); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const img = imageRef.current; + if (!canvas || !img) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + const box = drawIdText(ctx, text, canvas.width, canvas.height, config); + boxRef.current = box; + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = "rgba(14, 165, 233, 0.95)"; + ctx.lineWidth = Math.max(1, canvas.width / 500); + ctx.strokeRect(box.x - 6, box.y - 6, box.width + 12, box.height + 12); + ctx.restore(); + }, [config, text]); + + useEffect(() => { + if (!imageLoaded) return; + void document.fonts.ready.then(() => redraw()); + }, [redraw, imageLoaded, fontReadyTick]); + + const onPointerDown = (e: ReactPointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.setPointerCapture(e.pointerId); + if (hitBox(e.clientX, e.clientY, canvas, boxRef.current)) { + dragModeRef.current = "move"; + } else { + dragModeRef.current = "place"; + const p = eventToPercent(e.clientX, e.clientY, canvas); + patch({ xPercent: p.xPercent, yPercent: p.yPercent }); + } + }; + + const onPointerMove = (e: ReactPointerEvent) => { + if (dragModeRef.current === "none") return; + const canvas = canvasRef.current; + if (!canvas) return; + const p = eventToPercent(e.clientX, e.clientY, canvas); + patch({ xPercent: p.xPercent, yPercent: p.yPercent }); + }; + + const onPointerUp = (e: ReactPointerEvent) => { + dragModeRef.current = "none"; + const canvas = canvasRef.current; + if (canvas?.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId); + } + }; + + const onDropFile = (e: DragEvent) => { + e.preventDefault(); + setDropActive(false); + const file = e.dataTransfer.files[0]; + if (!file) return; + if (isFontFile(file)) takeFont(file); + else takeImage(file); + }; + + const onImageInput = (e: ChangeEvent) => { + takeImage(e.target.files?.[0] ?? null); + e.target.value = ""; + }; + + const onFontInput = (e: ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + if (file) takeFont(file); + e.target.value = ""; + }; + + const applyPreset = (preset: CardPreset) => { + setConfig(preset.config); + setImageFile(preset.image); + setFontFile(preset.font); + setActivePresetId(preset.id); + setPresetName(preset.name); + setDownloadHref(null); + }; + + const onSavePreset = async (asNew: boolean) => { + const name = presetName.trim(); + if (!name) { + setError("Name the preset first"); + return; + } + if (canGenerate && !imageFile) { + setError("Select a base photo before publishing a preset"); + return; + } + try { + let id = asNew ? undefined : (activePresetId ?? undefined); + if (canGenerate) { + const remote = await pushPresetToServer({ + id, + name, + config, + image: imageFile, + font: fontFile, + }); + id = remote.id; + } + const saved = await savePreset({ + id, + name, + config, + image: imageFile, + font: fontFile, + }); + const next = await listPresets(); + setPresets(next); + setActivePresetId(saved.id); + setPresetName(saved.name); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save preset"); + } + }; + + const onDeletePreset = async () => { + if (!activePresetId) return; + try { + if (canGenerate) { + await deleteServerPreset(activePresetId); + } + await deletePreset(activePresetId); + const next = await listPresets(); + setPresets(next); + setActivePresetId(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not delete preset"); + } + }; + + const generate = async () => { + if (!canGenerate) return; + if (!imageFile) { + setError("Select a base photo"); + return; + } + if (config.fontFamily === CUSTOM_FONT_FAMILY && !fontFile) { + setError("Upload a custom font or pick a preset font"); + return; + } + setError(null); + setDownloadHref(null); + setBusy({ kind: "generate", current: 0, total: config.count }); + const fd = new FormData(); + fd.append("image", imageFile); + if (config.fontFamily === CUSTOM_FONT_FAMILY && fontFile) { + fd.append("font", fontFile); + } + fd.append("config", JSON.stringify(config)); + try { + const res = await fetch("/api/generate", { method: "POST", body: fd }); + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("ndjson")) { + const body = (await res.json().catch(() => null)) as + | { error?: string } + | null; + throw new Error(body?.error || `Generate failed (${res.status})`); + } + await readNdjson(res, (ev) => { + if (ev.phase === "generate") { + setBusy({ kind: "generate", current: ev.current, total: ev.total }); + } else if (ev.phase === "zip") { + setBusy({ kind: "zip", current: ev.current, total: ev.total }); + } else if (ev.phase === "done") { + setDownloadHref(`/api/download/${ev.id}`); + setBusy({ kind: "idle" }); + } else if (ev.phase === "error") { + throw new Error(ev.message); + } + }); + setBusy({ kind: "idle" }); + } catch (err) { + setBusy({ kind: "idle" }); + setError(err instanceof Error ? err.message : "Generate failed"); + } + }; + + const busyLabel = + busy.kind === "generate" + ? `Generating ${busy.current} / ${busy.total}` + : busy.kind === "zip" + ? `Zipping ${busy.current} / ${busy.total}` + : "Generate"; + const progress = + busy.kind === "idle" + ? 0 + : busy.total > 0 + ? Math.round((busy.current / busy.total) * 100) + : 0; + const fontOptions = [ + ...PRESET_FONT_FAMILIES, + ...(fontFile || config.fontFamily === CUSTOM_FONT_FAMILY + ? [CUSTOM_FONT_FAMILY] + : []), + ]; + + return ( +
+
+
+

+ Founders card design +

+

+ {savedAt + ? `Autosaved locally ${new Date(savedAt).toLocaleTimeString()}` + : ready + ? "Autosave ready" + : "Loading saved config…"} + {saveError ? ` · ${saveError}` : ""} +

+
+ + ← Dashboard + +
+ +
+ { + setConfig((c) => ({ ...c, start: id })); + }} + /> +
{ + e.preventDefault(); + setDropActive(true); + }} + onDragLeave={() => setDropActive(false)} + onDrop={onDropFile} + > + {imageLoaded ? ( + + ) : imageFile ? ( +
+ Loading image… +
+ ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/src/app/founders-card-design/page.tsx b/src/app/founders-card-design/page.tsx new file mode 100644 index 0000000..32ca521 --- /dev/null +++ b/src/app/founders-card-design/page.tsx @@ -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 { + 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 ( +
+ + +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..56a597e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,3 +24,83 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Roboto"; + src: url("/fonts/Roboto-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Roboto"; + src: url("/fonts/Roboto-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Oswald"; + src: url("/fonts/Oswald-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Oswald"; + src: url("/fonts/Oswald-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Montserrat"; + src: url("/fonts/Montserrat-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Montserrat"; + src: url("/fonts/Montserrat-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Playfair Display"; + src: url("/fonts/PlayfairDisplay-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Playfair Display"; + src: url("/fonts/PlayfairDisplay-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} diff --git a/src/app/ledger-book/page.tsx b/src/app/ledger-book/page.tsx index 350e1f0..3c9d292 100644 --- a/src/app/ledger-book/page.tsx +++ b/src/app/ledger-book/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { AdminHeader } from "@/components/admin-header"; import { logPageAccess } from "@/lib/auth/log-page-access"; +import { navPageAccess } from "@/lib/auth/permissions"; import { requirePageRead } from "@/lib/auth/require-session"; import { formatRcDecimalFromCoinsBigInt, @@ -149,7 +150,12 @@ export default async function LedgerBookPage({ return (
- +

diff --git a/src/app/page.tsx b/src/app/page.tsx index 98d85e1..8fd1ad6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay"; import { canReadPage, canWritePage, + navPageAccess, tabToPageKey, } from "@/lib/auth/permissions"; import { requireSession } from "@/lib/auth/require-session"; @@ -104,14 +105,7 @@ export default async function Home({ const account = await requireSession(); const canWritePlayers = canWritePage(account, "players"); const canWriteLedger = canWritePage(account, "ledger"); - const pageAccess = { - 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 pageAccess = navPageAccess(account); const sp = await searchParams; const tabParam = firstSearchParam(sp.tab); @@ -414,7 +408,14 @@ export default async function Home({ return (
- + {configError ? (
{editUser ? ( diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index f2ff726..ab3dfad 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { requireAdmin } from "@/lib/auth/require-session"; import { listSessionAccounts } from "@/lib/auth/accounts-store"; import { logPageAccess } from "@/lib/auth/log-page-access"; +import { navPageAccess } from "@/lib/auth/permissions"; import { AdminHeader } from "@/components/admin-header"; import { AdminSettingsEditor } from "@/components/admin-settings-editor"; import { AdminAccountsEditor } from "@/components/admin-accounts-editor"; @@ -68,7 +69,12 @@ export default async function SettingsPage({ return (
- +

diff --git a/src/components/admin-accounts-editor.tsx b/src/components/admin-accounts-editor.tsx index 2a40bed..62beb1f 100644 --- a/src/components/admin-accounts-editor.tsx +++ b/src/components/admin-accounts-editor.tsx @@ -7,6 +7,7 @@ import { updateAdminAccount, } from "@/app/actions/account-actions"; import { + emptyPagePermissions, PAGE_KEYS, type PageKey, type PagePermissions, @@ -20,6 +21,7 @@ const PAGE_LABELS: Record = { matchmaker: "Matchmaker", ledger: "Ledger", logs: "System logs", + "founders-card": "Founders card", }; type Props = { @@ -254,14 +256,9 @@ function AccountEditForm({ } function AddAccountForm() { - const [permissions, setPermissions] = useState({ - players: { read: false, write: false }, - matches: { read: false, write: false }, - analysis: { read: false, write: false }, - matchmaker: { read: false, write: false }, - ledger: { read: false, write: false }, - logs: { read: false, write: false }, - }); + const [permissions, setPermissions] = useState(() => + emptyPagePermissions(), + ); 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(" "); - function editHref(u: DbUser): string { return buildDashboardHref({ tab, @@ -468,10 +449,6 @@ export function AdminDashboard({ }); } - const totalUsersLabel = - statsBundle?.stats?.totalUsers ?? users.length; - const totalMatchesLabel = - statsBundle?.stats?.totalMatches ?? matches.length; const statusLabel = (status: number | null) => { if (status === 2) return "Final"; if (status === 1) return "Live"; @@ -481,126 +458,6 @@ export function AdminDashboard({ return (
-
- - Overview - - {pageAccess.players ? ( - - Players ( - {totalUsersLabel.toLocaleString("en-US")} - {!statsBundle?.stats - ? ` · table ${users.length.toLocaleString("en-US")}` - : null} - ) - - ) : null} - {pageAccess.matches ? ( - - Matches ( - {totalMatchesLabel.toLocaleString("en-US")} - {!statsBundle?.stats - ? ` · table ${matches.length.toLocaleString("en-US")}` - : null} - ) - - ) : null} - {pageAccess.analysis ? ( - 0 - ? analysisPlayerIds.join(",") - : null, - })} - className={tabClass(tab === "analysis")} - scroll={false} - > - Analysis - - ) : null} - {pageAccess.matchmaker ? ( - - Matchmaker - - ) : null} - {pageAccess.ledger ? ( - - Ledger - - ) : null} - {pageAccess.logs ? ( - - System logs - - ) : null} - {isAdmin ? ( - - Settings - - ) : null} -
-
{tab === "dashboard" ? (
diff --git a/src/components/admin-header.tsx b/src/components/admin-header.tsx index 60c1cd6..5815321 100644 --- a/src/components/admin-header.tsx +++ b/src/components/admin-header.tsx @@ -1,39 +1,67 @@ "use client"; +import { + AdminNavTabs, + type AdminNavTab, +} from "@/components/admin-nav-tabs"; +import type { NavPageAccess } from "@/lib/auth/permissions"; + type Props = { username?: string; isAdmin?: boolean; + pageAccess?: NavPageAccess; + activeTab?: AdminNavTab; + playersLabel?: string; + matchesLabel?: string; }; -export function AdminHeader({ username, isAdmin = false }: Props) { +export function AdminHeader({ + username, + isAdmin = false, + pageAccess, + activeTab = "overview", + playersLabel, + matchesLabel, +}: Props) { async function logout() { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login"; } return ( -
-
-

- Kick Kings Admin Dashboard -

- {username ? ( -

- Signed in as{" "} - - {username} - - {isAdmin ? " · admin" : null} -

- ) : null} +
+
+
+

+ Kick Kings Admin Dashboard +

+ {username ? ( +

+ Signed in as{" "} + + {username} + + {isAdmin ? " · admin" : null} +

+ ) : null} +
+
- + {pageAccess ? ( + + ) : null}
); } diff --git a/src/components/admin-nav-tabs.tsx b/src/components/admin-nav-tabs.tsx new file mode 100644 index 0000000..cfb71e4 --- /dev/null +++ b/src/components/admin-nav-tabs.tsx @@ -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 ( + + ); +} diff --git a/src/lib/auth/page-access-labels.ts b/src/lib/auth/page-access-labels.ts index b7c8ee2..c0f850c 100644 --- a/src/lib/auth/page-access-labels.ts +++ b/src/lib/auth/page-access-labels.ts @@ -7,6 +7,8 @@ const PAGE_LABELS: Record = { ledger: "Ledger", logs: "System logs", settings: "Settings", + "founders-card": "Founders card", + "founders-card-design": "Founders card design", "ledger-book": "Ledger book", "match-log": "Match log", "match-replay": "Match replay", diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index 17864d3..7b55392 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -5,6 +5,7 @@ export const PAGE_KEYS = [ "matchmaker", "ledger", "logs", + "founders-card", ] as const; export type PageKey = (typeof PAGE_KEYS)[number]; @@ -27,6 +28,7 @@ export function emptyPagePermissions(): PagePermissions { matchmaker: { read: false, write: false }, ledger: { 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 }, ledger: { read: true, write: true }, logs: { read: true, write: false }, + "founders-card": { read: true, write: true }, }; } @@ -66,6 +69,28 @@ export type SessionAccount = { permissions: PagePermissions; }; +export type NavPageAccess = { + players: boolean; + matches: boolean; + analysis: boolean; + matchmaker: boolean; + ledger: boolean; + logs: boolean; + foundersCard: boolean; +}; + +export function navPageAccess(account: SessionAccount): NavPageAccess { + return { + players: canReadPage(account, "players"), + matches: canReadPage(account, "matches"), + analysis: canReadPage(account, "analysis"), + matchmaker: canReadPage(account, "matchmaker"), + ledger: canReadPage(account, "ledger"), + logs: canReadPage(account, "logs"), + foundersCard: canReadPage(account, "founders-card"), + }; +} + export function canReadPage( account: SessionAccount, page: PageKey, diff --git a/src/lib/card-gen/cors.ts b/src/lib/card-gen/cors.ts new file mode 100644 index 0000000..12236a3 --- /dev/null +++ b/src/lib/card-gen/cors.ts @@ -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" } }, + ); +} diff --git a/src/lib/card-gen/create-zip.ts b/src/lib/card-gen/create-zip.ts new file mode 100644 index 0000000..4368a01 --- /dev/null +++ b/src/lib/card-gen/create-zip.ts @@ -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 { + 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((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); +} diff --git a/src/lib/card-gen/draw-id.ts b/src/lib/card-gen/draw-id.ts new file mode 100644 index 0000000..de6c2c7 --- /dev/null +++ b/src/lib/card-gen/draw-id.ts @@ -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; +} diff --git a/src/lib/card-gen/early-player-emails.ts b/src/lib/card-gen/early-player-emails.ts new file mode 100644 index 0000000..ec24352 --- /dev/null +++ b/src/lib/card-gen/early-player-emails.ts @@ -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; + 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, + }; +} diff --git a/src/lib/card-gen/file-ext.ts b/src/lib/card-gen/file-ext.ts new file mode 100644 index 0000000..fa4a4ef --- /dev/null +++ b/src/lib/card-gen/file-ext.ts @@ -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); +} diff --git a/src/lib/card-gen/font-files.ts b/src/lib/card-gen/font-files.ts new file mode 100644 index 0000000..6c67b6c --- /dev/null +++ b/src/lib/card-gen/font-files.ts @@ -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 }, + ]; +} diff --git a/src/lib/card-gen/fonts.ts b/src/lib/card-gen/fonts.ts new file mode 100644 index 0000000..94e7120 --- /dev/null +++ b/src/lib/card-gen/fonts.ts @@ -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); diff --git a/src/lib/card-gen/generate-pool.ts b/src/lib/card-gen/generate-pool.ts new file mode 100644 index 0000000..b086913 --- /dev/null +++ b/src/lib/card-gen/generate-pool.ts @@ -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 { + 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((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}`)); + }); + }), + ), + ); +} diff --git a/src/lib/card-gen/id-config.ts b/src/lib/card-gen/id-config.ts new file mode 100644 index 0000000..da13442 --- /dev/null +++ b/src/lib/card-gen/id-config.ts @@ -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; + 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); +} diff --git a/src/lib/card-gen/jobs.ts b/src/lib/card-gen/jobs.ts new file mode 100644 index 0000000..aa7b591 --- /dev/null +++ b/src/lib/card-gen/jobs.ts @@ -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 { + 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 { + await mkdir(jobsRoot(), { recursive: true }); + await sweepExpiredJobs(); + const id = randomUUID(); + const job = jobPaths(id); + await mkdir(job.pngDir, { recursive: true }); + return job; +} diff --git a/src/lib/card-gen/local-store.ts b/src/lib/card-gen/local-store.ts new file mode 100644 index 0000000..f835f66 --- /dev/null +++ b/src/lib/card-gen/local-store.ts @@ -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 { + 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(req: IDBRequest): Promise { + 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 { + 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; + 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 { + 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) + : 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 { + 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 { + 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 { + 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 { + const db = await openDb(); + try { + const tx = db.transaction([PRESET_STORE], "readwrite"); + tx.objectStore(PRESET_STORE).delete(id); + await txDone(tx); + } finally { + db.close(); + } +} diff --git a/src/lib/card-gen/preset-api.ts b/src/lib/card-gen/preset-api.ts new file mode 100644 index 0000000..8a07d8c --- /dev/null +++ b/src/lib/card-gen/preset-api.ts @@ -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 { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + return body?.error || `Request failed (${res.status})`; +} + +export async function fetchServerPresetList(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + for (const preset of presets) { + await savePreset({ + id: preset.id, + name: preset.name, + config: preset.config, + image: preset.image, + font: preset.font, + }); + } +} diff --git a/src/lib/card-gen/preset-store.ts b/src/lib/card-gen/preset-store.ts new file mode 100644 index 0000000..80d270d --- /dev/null +++ b/src/lib/card-gen/preset-store.ts @@ -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 = Promise.resolve(); + +function enqueueWrite(fn: () => Promise): Promise { + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + }); +} diff --git a/src/lib/card-gen/progress.ts b/src/lib/card-gen/progress.ts new file mode 100644 index 0000000..31397ac --- /dev/null +++ b/src/lib/card-gen/progress.ts @@ -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; + 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; +} diff --git a/src/lib/card-gen/public-rate-limit.ts b/src/lib/card-gen/public-rate-limit.ts new file mode 100644 index 0000000..fc90c88 --- /dev/null +++ b/src/lib/card-gen/public-rate-limit.ts @@ -0,0 +1,40 @@ +const WINDOW_MS = 60_000; +const MAX_CARD_HITS = 60; +const MAX_SIGNUP_HITS = 10; + +const cardHitsByIp = new Map(); +const signupHitsByIp = new Map(); + +function checkLimit( + store: Map, + 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); +} diff --git a/src/lib/card-gen/render-one.ts b/src/lib/card-gen/render-one.ts new file mode 100644 index 0000000..3992e30 --- /dev/null +++ b/src/lib/card-gen/render-one.ts @@ -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 { + 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 }); + } +} diff --git a/src/lib/card-gen/render-public.ts b/src/lib/card-gen/render-public.ts new file mode 100644 index 0000000..26553df --- /dev/null +++ b/src/lib/card-gen/render-public.ts @@ -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 { + 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, +): 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, + }, + }); +} diff --git a/src/types/database.ts b/src/types/database.ts index 7c88886..7700303 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -56,6 +56,14 @@ export type DbTransaction = { 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). */ export type DbPingReport = { id: number; diff --git a/workers/generate-id.cjs b/workers/generate-id.cjs new file mode 100644 index 0000000..b4d36d9 --- /dev/null +++ b/workers/generate-id.cjs @@ -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; +});