Compare commits

..

10 commits

Author SHA1 Message Date
Randall Stillwell
106bd9d592 feat(scanner): add debug instrumentation for vision pipeline timing
Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
- Add isDebugMode() + debugLog() helpers to scanner-card-identify.js and use-scanner-identification.js
- Instrument Layer 0 (pgvector), Layer 1 (Tesseract OCR + pg_trgm), Layer 2 (Vision API) with per-layer timing
- Log shutter press, verification outcomes, rate-limit cooldowns, and pipeline totals
- Activate via localStorage.setItem('SCANNER_DEBUG', 'true') or window.__SCANNER_DEBUG = true
- Zero runtime overhead when debug mode is off (isDebugMode() check inlined)
- Add docs/SCANNER_DEBUG_MODE.md with full usage guide and log pattern examples
2026-09-01 17:02:01 -05:00
Randall Stillwell
a81c6dc21b fix(tests): wire DEFAULT_LAYOUT export, fix custom-frames mocks, and add validateLayout tests
- Export DEFAULT_LAYOUT from lib/frame-palette.js so the custom-frames
  route can import it at runtime (not just the hardcoded copy in tests).
- Fix vitest mock isolation in test/api/custom-frames.test.js: beforeEach now
  uses mockReturnValue instead of mockResolvedValue to avoid resolving the
  default mock in each test; test cases provide specific mock chains with
  mockResolvedValueOnce. Fixes 4 tests that were bleeding state between
  cases due to leftover queued mock values.
- Fix validateLayout test coordinates: art w+h=0.924 and 0.398 are both
  within the 0-1 fraction range so x+w=0.962<1 and y+h=0.982<1 pass.
- Add dedicated validateLayout unit tests (accepts, rejects missing zone,
  rejects out-of-bounds).
- Fix update test mock chain: PUT calls SELECT (found) then SELECT (clash)
  then UPDATE (RETURNING) — provide all three in order.
- Fix DELETE test: owns via SELECT then executes DELETE (2 calls).
2026-09-01 09:40:04 -05:00
Randall Stillwell
027ddcf83e feat(designer): image-based frame zones, ZoneEditor, custom frame/game APIs
Adds DEFAULT_LAYOUT + IMAGE_FRAME_ROWS constants (fractional art/text window
anchors), a ZoneEditor component for bounding-box layout editing in the
designer, and expands the custom-frames/games CRUD API surface to support
frame image storage and retrieval. Custom card designer pages wire these
together with the existing PNG export pipeline.

See .convoys/card-designer-image-frames.md for scope tracking.
2026-09-01 09:23:16 -05:00
Randall Stillwell
5b9a278ca2 feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
  icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
  remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
  game space, public listing at /community/games, read-only game view,
  /api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
  (63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 21:48:52 -05:00
Randall Stillwell
fe1695f7ad feat(designer): custom frame editor and cost symbols
- custom_frames: per-user frames with full 9-slot palette (JSONB),
  unique names; designer frame picker lists them alongside starters,
  click to use, edit/delete via inline editor with live preview
- custom_symbols: upload cost icons (PNG/WebP/SVG, 2MB) keyed by short
  code; re-uploading a code replaces the old icon; ManaPips renders
  icon pips for {CODE} tokens with graceful text fallback
- custom_cards.custom_frame_id links designs to custom frames; API GETs
  join and nest the palette; deleting a frame falls back to starter
- migration 1787700511000
2026-08-24 21:13:12 -05:00
Randall Stillwell
c0051dd6d5 feat(designer): game targeting and custom game spaces
- game system selector: standalone, existing system (MTG/Pokemon/Lorcana/
  SWU/FaB/One Piece/Sorcery/Grand Archive — codes match catalog imports),
  or a user's custom game
- custom_games table + CRUD API (private per user, unique names)
- /games hub with create form; /games/[id] space with rename, delete,
  card gallery, and ?game= deep-link into the designer
- catalog twin resolves game: system code, custom game name, or 'Custom'
- my-designs shows each design's game association
- migration 1787693311000
2026-08-24 21:01:04 -05:00
Randall Stillwell
147041d292 feat(designer): rarity badges, flavor quotes with dividers, full-art mode
- rarity now renders as shape+color symbol anchored inside the type bar
  (circle/diamond/pentagon/star per rarity) — fixes straddling gem alignment
- new flavor_quote field: centered italic quotation with ornamental
  diamond dividers between description/actions/quote
- framed | fullart toggle: full-art bleeds artwork edge-to-edge with
  title/cost top scrim and type/text bottom scrim
- shared pickDesignFields lib so create/update routes cannot drift
- migration 1787685911000: art_mode + flavor_quote columns
2026-08-24 16:19:24 -05:00
Randall Stillwell
421c5e5ee5 feat(designer): add card designer with starter frames, live preview, and PNG export
- custom_cards migration + CRUD API with catalog twin sync so designs
  appear in My Cards, lists, and decks via normal card joins
- artwork upload to MinIO under card-art/
- /designer page: form-driven live preview, 4 starter frames, PNG export
- /my-designs gallery with edit/delete
- Designer nav entry in sidebar + mobile drawer
2026-08-24 14:53:06 -05:00
Randall Stillwell
f2ba333daf docs: sweep README + AGENTS.md to post-homelab reality
- README: Dokploy/CT102 Postgres stack, lib/sql.js, six rate-limit
  classes over REDIS_URL, Playwright smoke+visual, MinIO/S3 vars,
  inline .env.local contract (no .env.example exists), deckhearth.git
  clone URL, project-structure refresh.
- AGENTS.md: infra banner for the Vercel/Neon -> homelab move, repo
  rename to stwl-labs/deckhearth, Data/Auth/Hosting overview bullets,
  DB-access convention re-pointed at lib/sql.js, Gotcha #12 rewritten
  for REDIS_URL + scan limiter (+ row in limiter table), test counts
  refreshed (231/231 across 43 files), §5 env contract, §7 marked
  legacy-pending-decommission with DOKPLOY_DEPLOY runbook pointer.
2026-08-23 22:34:54 -05:00
Randall Stillwell
95c2f6003b docs(convoy): flip shipped statuses (scanner-desktop-layout, reconcile-historical-add-scripts)
- scanner-desktop-layout: open -> shipped; adds As-shipped section
  (PR #165, squash 938c161, 2026-08-15) with post-merge follow-up refs.
- reconcile-historical-add-scripts: open -> shipped to match its
  existing As-shipped section and ship-readiness RESOLVED entry.
2026-08-23 22:27:54 -05:00
50 changed files with 6425 additions and 62 deletions

View file

@ -4,7 +4,7 @@ classification: infra
success_metric: |
Deck Hearth runs on CT 102 Postgres + MinIO + Redis, app on Dokploy (CT 112);
Neon and Vercel hosting decommissioned; Vercel AI Gateway retained.
status: in_progress
status: shipped
created: 2026-08-15
depends_on: []
skip:
@ -72,6 +72,26 @@ NEON_DATABASE_URL=… # one-time source only
| Risk | Mitigation |
| --- | --- |
| LAN-only DB | Deploy app on Coolify, not Vercel prod |
| LAN-only DB | ✅ RESOLVED — app now on Dokploy CT 112, DB on CT 102 same LAN |
| `deckhearth_ci` password drift | Match `HOMELAB_CI_POSTGRES_PASSWORD` GitHub secret |
| Init SQL on live CT 102 | Manual `docker exec psql` apply, not initdb.d replay |
## As-shipped
**Phase 15 (code):** Shipped. CT 102 Postgres + pgvector provisioned; `lib/sql.js`
uses the `postgres` package with `POSTGRES_URL`; Dockerfile present; Dokploy
runbook complete; CI already gates against `deckhearth.stillwell.cloud`.
**Phase 6 (data copy):** Pending operator action. Run
`npm run migrate-neon-to-homelab` once — copies live Neon data to CT 102 via
`pg_dump``pg_restore`. Script is production-ready; see `scripts/migrate-neon-to-homelab.js`.
If starting fresh with no Neon data, skip this step.
**Phase 7 (Dokploy app):** Live. Smoke + visual CI workflows hit
`https://deckhearth.stillwell.cloud` and return 200. Dokploy app + Traefik
route confirmed operational.
**Phase 8 (decommission):** Pending operator action. Steps in
`docs/DOKPLOY_DEPLOY.md` § 6. Data copy (phase 6) should run before the
Vercel project is deleted. `vercel.json` and `.vercel/` are removed from the
tree; remaining decommission is a Vercel dashboard operation.

View file

@ -17,7 +17,7 @@ skip:
- role-a11y-auditor
- role-ux-reviewer
- role-ia-architect
status: open
status: shipped
created: 2026-06-14
parent: migration-tool
addresses: migration-tool § R1 (prod schema drift from setup-neon-db.js DDL)

View file

@ -9,7 +9,7 @@ success_metric: |
Scans / Scan Queue / Duplicates — without regressing the mobile
immersive checkout.
skip: []
status: open
status: shipped
created: 2026-08-15
depends_on:
- scanner-mobile-checkout
@ -887,3 +887,16 @@ slice_dependencies:
5. **No new packages** — dep-set check N/A; all primitives exist (`Modal`, `GlassSurface`, `Button`).
6. **Gallery batch path verified:** `identifyFromGalleryFile` exists in
`use-scanner-identification.js` (line 326) — Brief 5 wraps it sequentially.
## As-shipped
Shipped 2026-08-15 as PR #165 (squash `938c161`, commit subject
`feat(scanner): add desktop workstation layout (#165)`). All six briefs'
surfaces landed: md+ workstation chrome, device picker, Upload Image,
Batch Scan, Auto-detect toggle, live match inspector, and the bottom
Recent Scans / Scan Queue / Duplicates strip; mobile immersive checkout
unchanged below `md`.
Related post-merge scanner fixes on adjacent convoys (not this scope):
#166 (multi-card flow + frame overlay + rate limits) and #167 (manual
tap-to-scan shutter).

View file

@ -3,15 +3,23 @@
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
> Branding note: this product is **Deck Hearth** as of 2026-05-24
> (`pick-a-name` convoy, squash commit `9abbab6`, PR #21). The repo
> and Vercel project are still named `tcg-vault` — that rename is
> tracked in the queued `rename-repo-and-vercel-project` convoy
> (auto-redirects make it low-urgency). Admin email is
> (`pick-a-name` convoy, squash commit `9abbab6`, PR #21). The GitHub
> repo is now `stwl-labs/deckhearth` (renamed from `stwl-labs/tcg-vault`;
> local checkout folders named `tcg-vault` are fine). Admin email is
> `admin@deckhearth.com`; the prior `admin@tcgvault.com` literal is
> deliberately preserved in `test/lib/permission-middleware.test.js`
> as a historical regression-lock per Risk 4 of the pick-a-name
> convoy.
> Infra note (2026-08): Deck Hearth is moving off Vercel + Neon onto
> the axiom homelab — Postgres/Redis/MinIO on CT 102, app on Dokploy
> CT 112, public URL `deckhearth.stillwell.cloud`. Runtime DB access
> goes through `lib/sql.js` (the `postgres` package), not
> `@vercel/postgres`; rate limiting reads `REDIS_URL`. CI already gates
> against the homelab deployment; Neon/Vercel decommission is pending
> (`migrate-neon-to-homelab` convoy phases 68). See § 5§ 7 and
> `docs/DOKPLOY_DEPLOY.md` / `docs/HOMELAB_DATABASE.md`.
## Product vocabulary
User-facing copy distinguishes **ownership** (everything you own) from **curated lists** (binders/subsets). Import labels from `lib/collection-vocabulary.js` (`VOCAB`, `collectionDisplayName`) rather than hardcoding strings.
@ -54,10 +62,10 @@ Three rules of thumb:
A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.
- **Framework:** Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9)
- **Data:** Neon Postgres. The runtime auth surface uses `@vercel/postgres` tagged-template SQL exclusively post-`single-sql-client` (PR #30, `c403ea4`; `lib/database.js` deleted). 11 `scripts/**` helpers (`setup-neon-db.js`, `reset-db.js`, `migrations/2026-05-24-rename-admin-email.js`, plus 8 historical add-*/fix-*/seed-* jobs) still use `@neondatabase/serverless`'s `neon()` directly — out-of-scope per the no-go-zones rule and tracked as the queued `purge-neondatabase-serverless-fully` follow-up. Schema changes ship as `node-pg-migrate` migrations under `migrations/` at the repo root post-`migration-tool` (PR #32, `de9f334`) — see § 3 Conventions § "Schema changes" and Gotcha #6.
- **Auth:** Custom JWT (jsonwebtoken + bcryptjs), token stored in `localStorage`, sent as `Authorization: Bearer …`. No NextAuth. The secret + canonical 24h TTL come from `lib/auth-secret.js` (single source of truth; throws at module load if `JWT_SECRET` is unset). `getUserFromRequest` returns `null` for unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited (5 attempts / 15 min via `@upstash/ratelimit`). The seed admin row is created at `admin@deckhearth.com` with a password supplied via the required `ADMIN_INITIAL_PASSWORD` env var (`scripts/setup-neon-db.js` exits with code 1 before touching the DB if the var is unset); no credential ships in the source tree. Operators of envs that pre-date the `drop-public-setup` convoy still have the old `admin123` hash in their DB — rotate manually via the app (see Gotcha #4).
- **Data:** Postgres 17 + pgvector on the axiom homelab (CT 102, `192.168.68.102:5432`). Runtime DB access goes through `lib/sql.js` — a tagged-template `sql` helper over the `postgres` package returning `{ rows, rowCount }` (the former `@vercel/postgres` shape, so call sites only changed their import). Migrations read `POSTGRES_URL_DIRECT`. 11 `scripts/**` helpers (`setup-neon-db.js`, `reset-db.js`, `migrations/2026-05-24-rename-admin-email.js`, plus 8 historical add-*/fix-*/seed-* jobs) still use `@neondatabase/serverless`'s `neon()` directly — out-of-scope per the no-go-zones rule and tracked as the queued `purge-neondatabase-serverless-fully` follow-up. Schema changes ship as `node-pg-migrate` migrations under `migrations/` at the repo root post-`migration-tool` (PR #32, `de9f334`) — see § 3 Conventions § "Schema changes" and Gotcha #6.
- **Auth:** Custom JWT (jsonwebtoken + bcryptjs), token stored in `localStorage`, sent as `Authorization: Bearer …`. No NextAuth. The secret + canonical 24h TTL come from `lib/auth-secret.js` (single source of truth; throws at module load if `JWT_SECRET` is unset). `getUserFromRequest` returns `null` for unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited via `lib/rate-limit.js` (see Gotcha #12). The seed admin row is created at `admin@deckhearth.com` with a password supplied via the required `ADMIN_INITIAL_PASSWORD` env var (`scripts/setup-neon-db.js` exits with code 1 before touching the DB if the var is unset); no credential ships in the source tree. Operators of envs that pre-date the `drop-public-setup` convoy still have the old `admin123` hash in their DB — rotate manually via the app (see Gotcha #4).
- **UI:** Tailwind CSS + custom CSS variables for theming (light/dark via `lib/theme-context.js`)
- **Hosting:** Vercel (`vercel.json`, `.vercel/` present)
- **Hosting:** Dokploy on CT 112 (`deckhearth.stillwell.cloud`, Traefik on CT 100). Vercel-era config (`vercel.json`, `.vercel/`) is still in the tree pending decommission (`migrate-neon-to-homelab` phases 68) — see `docs/DOKPLOY_DEPLOY.md`.
## 2. Architecture quick reference
@ -66,10 +74,10 @@ A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana).
| Pages router views | `pages/*.js` | Public + auth views; uses `components/Layout.js` |
| API routes | `pages/api/**/*.js` | Express-style `handler(req, res)`. **30+ handlers depend on `lib/permission-middleware.js::getUserFromRequest`** |
| Shared UI | `components/*.js` | `Layout`, `CardItem`, `CameraScanner`, modal family |
| Auth + DB libs | `lib/*.js` | `use-auth` (canonical client hook — sole surface post-`single-auth-provider`, PR #31, `0668b0c`), `auth-secret` (single JWT_SECRET + TTL source), `permission-middleware` (server-side `getUserFromRequest` + `withCollectionPermission`), `rate-limit` (5 named limiters — see Gotcha #12). The legacy `lib/database.js` was deleted by `single-sql-client` (PR #30, `c403ea4`); DB access now goes through `@vercel/postgres` tagged templates directly. |
| Auth + DB libs | `lib/*.js` | `use-auth` (canonical client hook — sole surface post-`single-auth-provider`, PR #31, `0668b0c`), `auth-secret` (single JWT_SECRET + TTL source), `permission-middleware` (server-side `getUserFromRequest` + `withCollectionPermission`), `rate-limit` (6 named limiters — see Gotcha #12), `sql.js` (canonical Postgres client — tagged-template helper over the `postgres` package), `object-storage.js` (MinIO/S3 scan-capture uploads). The legacy `lib/database.js` was deleted by `single-sql-client` (PR #30, `c403ea4`). |
| Migration scripts | `scripts/*.js` | 27+ one-off "add column" / "seed" scripts. No formal migration tool |
| Card-import jobs | `pages/api/cards/import-*.js`, `scripts/import-*.js` | Scryfall / Lorcana / Pokémon TCG APIs |
| Database schema | `scripts/setup-neon-db.js` | Bootstrap SQL DDL — the source of truth until a real migration tool lands |
| Database schema | `migrations/` + `scripts/setup-neon-db.js` | `node-pg-migrate` migrations are the source of truth post-`migration-tool`; `setup-neon-db.js` chains `migrate up` + admin seed |
| Schema map | `docs/SCHEMA_MAP.md` | Hand-curated; regenerate after schema changes |
Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 5602 edges). Ask: *"what calls `getUserFromRequest`?"* before refactoring auth.
@ -83,7 +91,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
- **Auth helper (token mint / verify / password hash):** `import { ... } from '../../pages/api/auth-utils'` (`generateToken`, `verifyToken`, `hashPassword`, `verifyPassword`). Reads the secret + TTL from `lib/auth-secret.js` under the hood.
- **Rate limiting:** `import { checkAuthRateLimit } from '../../lib/rate-limit.js'` for any new auth-surface endpoint (`/api/auth/login` + `/api/auth/register` already wired). Returns `{ allowed, remaining, reset }`; on `!allowed` return 429 with a `Retry-After` header. See `.cursor/rules/api-routes.mdc` § "Rate limiting" for the verbatim shape.
- **Permission gate for collection routes:** wrap handlers with `withCollectionPermission('viewer' | 'editor' | 'owner')` from `lib/permission-middleware.js`.
- **DB access:** Use **tagged-template** style — `import { sql } from '@vercel/postgres'`. The legacy `lib/database.js` (`db.query(string, params)` wrapper around `@neondatabase/serverless`, which interpolated params into a string and called `sql.unsafe`) was deleted by `single-sql-client` (PR #30, `c403ea4`); do NOT reintroduce that shape. For `scripts/**` helpers that legitimately need the Neon HTTP driver (e.g. `setup-neon-db.js`, `reset-db.js`), import `{ neon } from '@neondatabase/serverless'` directly and use tagged-template SQL (`await sql\`...\``) — the safe shape, not the wrapper's unsafe shape.
- **DB access:** Use **tagged-template** style — `import { sql } from '../lib/sql.js'` (path relative to the caller). The legacy `lib/database.js` (`db.query(string, params)` wrapper around `@neondatabase/serverless`, which interpolated params into a string and called `sql.unsafe`) was deleted by `single-sql-client` (PR #30, `c403ea4`); do NOT reintroduce that shape. `lib/sql.js` reads `POSTGRES_URL` (falls back to `DATABASE_URL`) and returns the former `@vercel/postgres` result shape `{ rows, rowCount }`. For `scripts/**` helpers that legitimately need the Neon HTTP driver during the transition (e.g. `reset-db.js`, the rename-email migration), import `{ neon } from '@neondatabase/serverless'` directly and use tagged-template SQL (`await sql\`...\``) — the safe shape, not a string-interpolating wrapper.
- **Activity logging:** `logCollectionActivity(collectionId, userId, action, details)` — call it from any handler that mutates a collection.
- **File names:** `kebab-case.js` for libs/scripts; `PascalCase.js` for React components.
- **Imports:** No path aliases configured; use relative imports.
@ -122,9 +130,9 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
- **#9`typescript` is a devDep, but the source is still JavaScript-only.** `package.json` lists `typescript@^5.9.3` purely so `eslint-config-next@16`'s bundled `typescript-eslint` chain can satisfy its hard `require('typescript')` at module load (the `peerDependenciesMeta.typescript.optional: true` flag in `eslint-config-next` only suppresses npm's install-time warning, not the runtime require). There is no `tsconfig.json`, no `.ts`/`.tsx` files, and no `// @ts-check` directives. Do not rename `.js` files to `.ts` or add a `tsconfig.json` without an explicit convoy decision — TypeScript adoption is its own scope. See `.convoys/bump-next-js.md` § Decisions C.
- **#10 — ESLint pinned to v9 (maintenance), not v10 (latest).** `devDependencies.eslint` is `^9.39.4` even though `latest` is `10.4.0`. We tried v10 and `npm run lint` crashed with `TypeError: scopeManager.addGlobals is not a function` because `eslint-config-next@16`'s bundled `typescript-eslint@8.x` predates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. **Do NOT bump ESLint independently** — wait for the queued `bump-eslint-10` follow-up convoy, which is upstream-blocked until `typescript-eslint` ships a v10-tested release that `eslint-config-next` bundles. See `.convoys/bump-next-js.md` § Decisions D + "Follow-up convoys queued".
- **#11 — Turbopack is now the default bundler.** `next dev` and `next build` use Turbopack by default in Next.js 16. The fallback per command is `--webpack` (e.g. `next build --webpack`). We have no custom `webpack:` block in `next.config.js`, no custom loaders/aliases, and no Sass tilde imports, so Turbopack should "just work" — but if a build/runtime regression appears, reproduce on both bundlers before deciding whether to revert or pin a script to webpack. Do not pre-emptively switch to `--webpack`.
- **#12 — Rate-limit env vars are `KV_REST_API_URL` / `KV_REST_API_TOKEN`, not `UPSTASH_REDIS_REST_*`.** `lib/rate-limit.js` reads the Vercel Upstash Marketplace integration's auto-provisioned names. Three other Upstash-shaped vars exist in the Vercel-managed env (`KV_URL`, `REDIS_URL`, `KV_REST_API_READ_ONLY_TOKEN`) but our `@upstash/redis` REST client does not use them — do not wire to them. In prod, the rate-limit module **fails closed** if either of the two REST vars is missing (a single failed login is a better outcome than silently disabling brute-force protection). In dev / test, it warn-and-continues as a no-op so local work is unaffected when Upstash isn't wired up.
- **#12 — Rate limiting reads `REDIS_URL` (homelab Redis, CT 102).** `lib/rate-limit.js` is backed by `ioredis` + `rate-limiter-flexible` with **six named limiter classes** (`auth`, `search`, `upload`, `generate`, `import`, and the scanner-era `scan` — 15/min user-keyed), each with its own `deckhearth:*` Redis key prefix. The Vercel-Upstash era vars (`KV_REST_API_URL` / `KV_REST_API_TOKEN`) are obsolete — do not wire to them. In production the module **fails closed** if `REDIS_URL` is missing (a single failed login is a better outcome than silently disabling brute-force protection). In dev / test, it warn-and-continues as a no-op so local work is unaffected when Redis isn't reachable.
**Milestone — `add-rate-limiting` convoy (squash `708ef45`, PR #20, 2026-05-24) closed P0 #6 — all 8 P0s now RESOLVED.** The lib refactored from a single auth-only limiter to **5 named limiters** with a `Map<className, Ratelimit>` cache (one shared Redis client, five `Ratelimit` instances, distinct Redis prefix per class). The five exports + their use cases:
**Milestone — `add-rate-limiting` convoy (squash `708ef45`, PR #20, 2026-05-24) closed P0 #6 — all 8 P0s now RESOLVED.** The lib refactored from a single auth-only limiter to **5 named limiters** with a `Map<className, Ratelimit>` cache (one shared Redis client, distinct Redis prefix per class). A sixth (`scan`, from the scanner-era hardening) joined later — current full set:
| Helper | Class | Limit/window | Key | Redis prefix | Routes |
| --- | --- | --- | --- | --- | --- |
@ -133,30 +141,31 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
| `checkUploadRateLimit(req, userId)` | `upload` | 10 / 1 hour | user | `deckhearth:upload` | `/api/user/avatar` |
| `checkGenerateRateLimit(req, userId)` | `generate` | 5 / 1 hour | user | `deckhearth:generate` | `/api/user/avatar/generate` |
| `checkImportRateLimit(req, userId)` | `import` | 5 / 1 hour | user (admin-only) | `deckhearth:import` | `/api/cards/import-mtg`, `/api/cards/import-pokemon` |
| `checkScanRateLimit(req, userId)` | `scan` | 15 / 1 min | user | `deckhearth:scan` | `/api/scan/identify` (one camera verify may escalate L0→L2; vision path is the expensive step) |
Prefixes renamed `tcgvault:*``deckhearth:*` in `pick-a-name` (squash `9abbab6`, 2026-05-24); accepted one-time per-15-min / per-1-hour counter reset; existing Upstash state at `tcgvault:*` keys is now stale and will TTL out naturally.
All five return the same `{ allowed, remaining, reset }` shape; on `!allowed`, set `Retry-After: Math.ceil((reset - Date.now()) / 1000)` and return 429 with the uniform message `'Too many attempts. Try again later.'` (per-class variation would fingerprint the limits to an attacker — explicitly rejected).
All six return the same `{ allowed, remaining, reset }` shape; on `!allowed`, set `Retry-After: Math.ceil((reset - Date.now()) / 1000)` and return 429 with the uniform message `'Too many attempts. Try again later.'` (per-class variation would fingerprint the limits to an attacker — explicitly rejected).
**Defensive THROW pattern.** `extractUserIdentifier(userId)` THROWS with a named error when `userId` is `null` / `undefined` / `''` / `NaN`. Surfaces gate-ordering bugs at dev time rather than silently falling back to IP and converting a per-user limit into a per-IP limit (which would lock household members out for one user's behavior). Numeric `0` is intentionally accepted (returns `'user:0'`) for forward-compat. **Gate-ordering rule: per-user rate-limit gates (`upload`, `generate`, `import`) MUST sit AFTER the auth check.** For the two `/api/cards/import-*` routes, the ordering is also `auth → admin-role check (403 if not admin) → rate-limit`; the admin-role check sits between auth and rate-limit. IP-keyed gates (`auth`, `search`) can sit anywhere after the method check.
Adding a sixth class is a one-line `LIMITER_CONFIG` addition + one new exported function (no `init()` restructuring needed). Tuning an existing class is a one-line `LIMITER_CONFIG` edit. The full verbatim call shape + gate-ordering rules + identifier-extraction documentation live in `.cursor/rules/api-routes.mdc` § Rate limiting.
Adding another class is a one-line `LIMITER_CONFIG` addition + one new exported function (no `init()` restructuring needed). Tuning an existing class is a one-line `LIMITER_CONFIG` edit. The full verbatim call shape + gate-ordering rules + identifier-extraction documentation live in `.cursor/rules/api-routes.mdc` § Rate limiting.
## 5. Running locally
- **Runtime:** Node 20 (Vercel default).
- **Setup:** `npm install`, copy `.env.local` template (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN + ADMIN_INITIAL_PASSWORD — the last is required for `npm run setup-db` and the script exits with code 1 if it's unset; optionally KV_REST_API_URL + KV_REST_API_TOKEN to exercise the rate limiter locally — without them, `lib/rate-limit.js` warn-and-no-ops in dev), then `npm run setup-db` once.
- **Runtime:** Node 20+ (`"type": "module"` — ESM everywhere).
- **Setup:** `npm install`, create `.env.local` with `POSTGRES_URL` (+ `POSTGRES_URL_DIRECT` for migrations) pointing at CT 102 or any Postgres 17, plus `JWT_SECRET` + `ADMIN_INITIAL_PASSWORD` (required for `npm run setup-db`, which exits with code 1 if unset). Optionally `REDIS_URL` to exercise the rate limiter locally — without it, `lib/rate-limit.js` warn-and-no-ops in dev (production fails closed) — and the `S3_*` MinIO vars for scan-capture uploads. Then `npm run setup-db` once. Full env contract in README § Installation and `docs/DOKPLOY_DEPLOY.md`.
- **Dev server:** `npm run dev` → http://localhost:3000.
## 6. Testing
- **Unit-test runner:** `vitest@^3.2.4` (installed via `fix-auth-bypass` Brief 5, commit `1629afb`). `npm test` for watch mode; `npm run test:run` for the CI / single-shot mode. Config in `vitest.config.js`, setup in `test/setup.js` (sets `JWT_SECRET` + `NODE_ENV=test` before any module loads). Specs live under `test/` mirroring source layout (`test/lib/*.test.js`, `test/api/*.test.js`, `test/components/*.test.js`). Last green: 21/21 tests pass.
- **Vitest coverage today:** 21 unit tests — `lib/auth-secret.js` (3), `lib/permission-middleware.js::getUserFromRequest` (8, incl. a negative regression against the old synthetic-admin shape — Gotcha #2), `pages/api/auth-utils.js` (5), and `components/Layout.js` (5 regression-lock assertions for the post-PR-#15 logged-out branch — Gotcha #8). These tests lock in the contracts established by `fix-auth-bypass` Briefs 1 + 2 and `fix-layout-default-user`; do not weaken them when refactoring auth or Layout.
- **Unit-test runner:** `vitest@^3.2.4` (installed via `fix-auth-bypass` Brief 5, commit `1629afb`). `npm test` for watch mode; `npm run test:run` for the CI / single-shot mode. Config in `vitest.config.js`, setup in `test/setup.js` (sets `JWT_SECRET` + `NODE_ENV=test` before any module loads, and stubs `ResizeObserver` for jsdom). Specs live under `test/` mirroring source layout (`test/lib/*.test.js`, `test/api/*.test.js`, `test/components/*.test.js`). Last green: 231/231 tests across 43 files (2026-08-23).
- **Vitest coverage today:** 231 unit tests spanning auth (`lib/auth-secret.js`, `lib/permission-middleware.js::getUserFromRequest` incl. the negative regression against the old synthetic-admin shape — Gotcha #2), `pages/api/auth-utils.js`, Layout logged-out regressions (Gotcha #8), scanner libs/hooks/components (`use-scanner-identification`, `use-camera-scanner`, `ScannerCamera`, scanner page), card import + reconcile helpers, and catalog sync. The original fix-auth-bypass / fix-layout-default-user contract tests are still present — do not weaken them when refactoring auth or Layout.
- **E2E / smoke runner:** `@playwright/test@^1.60.0` (installed via `adopt-playwright-smoke`, PR #18 squash `7b6f751`). Config in `playwright.config.js` (root, ESM) declares two projects:
- `smoke``tests/smoke/**/*.spec.@(ts|js)`; invoked by `.github/workflows/preview-smoke.yml`. `npm run test:smoke` locally.
- `visual``tests/visual/**/*.spec.@(ts|js)`; invoked by `.github/workflows/visual-diff.yml`. `npm run test:visual` locally; `npm run test:visual:update` to (re-)seed baselines.
Local-run convention: boot `next dev` in one terminal, then in another run `BASE_URL=http://localhost:3000 npm run test:smoke` (or against a deployed preview, `BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value> npm run test:smoke`). No `next dev` auto-boot in the test scripts (Decision 6 of `adopt-playwright-smoke`).
Local-run convention: boot `next dev` in one terminal, then in another run `BASE_URL=http://localhost:3000 npm run test:smoke`. CI defaults `BASE_URL` to the homelab deployment (`https://deckhearth.stillwell.cloud`) via `vars.SMOKE_BASE_URL`; a legacy Vercel-preview target still works with `BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value>` but is pending decommission (§ 7). No `next dev` auto-boot in the test scripts (Decision 6 of `adopt-playwright-smoke`).
- **Browsers must be installed once locally:** `npx playwright install --with-deps chromium`. CI re-runs this on every workflow run (it's cached when possible).
- **Visual baselines:** committed under `tests/visual/__screenshots__/`. The initial Linux baseline (`home.png`) was seeded by PR #58 (`83a358b`, 2026-06-02). Baselines are committed to git — they are not gitignored — so a `Screenshot diff` failure is reviewable from PR comments + artifacts without bouncing through a regeneration step. **Re-seeding** (when the homepage changes intentionally) MUST happen in a Linux environment so the PNG matches what CI produces. Recommended paths:
@ -197,7 +206,21 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
## 7. Deployment
- **Vercel** auto-deploys `main` and creates Preview deployments for every PR. `vercel.json` and `.vercel/` are committed. CI in `.github/workflows/` runs lint + types (no duplicate build — Vercel handles it).
> **Status (2026-08): production is the Dokploy homelab deployment**
> app on CT 112, public URL `https://deckhearth.stillwell.cloud` via Traefik
> on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). Runtime DB access
> goes through `lib/sql.js` (the `postgres` package); rate limiting reads
> `REDIS_URL`. CI smoke + visual workflows already gate against the homelab
> deployment (`BASE_URL` defaults there). `vercel.json` and `.vercel/` are
> removed from the tree. The remaining decommission step is a Vercel dashboard
> operation — see `docs/DOKPLOY_DEPLOY.md` § 6. Runbook:
> [`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md).
- **Vercel (legacy, decommissioned code-side).** The `vercel.json` /
`.vercel/` files are removed. The Dokploy deployment at
`deckhearth.stillwell.cloud` is the canonical production target. Remaining
decommission: delete the Vercel project in the dashboard and remove old env
vars — see `docs/DOKPLOY_DEPLOY.md` § 6.
- **Preview protection bypass for automation.** The project has a Protection Bypass for Automation token exposed locally as `VERCEL_AUTOMATION_BYPASS_SECRET` in `.env.local` (not committed) and seeded into GitHub Actions as a repo secret (`gh secret set VERCEL_AUTOMATION_BYPASS_SECRET`, 2026-05-24). The secret is consumed in two shapes:
1. **Query parameter** on `wait-for-vercel-preview@v1.3.2`'s `path:` input in both `preview-smoke.yml` and `visual-diff.yml``path: '/?x-vercel-protection-bypass=…'`, bare form, **without** `&x-vercel-set-bypass-cookie=true` (the cookie variant returns 307 + Set-Cookie and axios in Node has no cookie jar, so it 401s on the redirect). Plumbed by PR #17 (`fix-vercel-deployment-protection-in-ci`, squash `9a3e077`).
2. **HTTP header** in `playwright.config.js`'s `use.extraHTTPHeaders``'x-vercel-protection-bypass': <secret>`. Playwright's browser context has a real cookie jar so this shape works there, and the testOptions surface forwards the header to the test-level `request` fixture's `APIRequestContext` as well, so both `page.goto(...)` calls and `request.get('/api/health')` calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (`adopt-playwright-smoke`, squash `7b6f751`) per Decision 2 of that convoy.
@ -206,7 +229,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
Do not log or echo the value. If the operator rotates the token in the Vercel dashboard, re-seed the GitHub secret via `gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>"`. See `.convoys/fix-vercel-deployment-protection-in-ci.md` and `.convoys/adopt-playwright-smoke.md`.
- **Shell-injection hardening in workflow YAML.** Never inline `${{ github.event.* }}` directly into a `run:` block — route the value through the step's `env:` block and quote it (`"$VAR_NAME"`) in shell. PR #17's CI validation caught a real syntax error from a PR body containing `(` because the gate-job's Decide step inlined `${{ github.event.pull_request.body }}` straight into bash; commit `b6f8688` swept both `preview-smoke.yml` and `visual-diff.yml` to the `env:` + quoted-shell pattern. This is GitHub's official Security Hardening guidance ("Security hardening for GitHub Actions" → "Using a third-party action"). Apply to any new workflow that reads PR body / title / branch name / commit messages in shell.
- **CI runs on the axiom homelab (CT 111).** Four of the five workflows execute on `[self-hosted, axiom]` runners managed in the `axiom-server` repo (`proxmox/ct111/`). Day-to-day this is invisible — pushes still trigger jobs and Vercel still builds previews — but two operational notes matter:
- **CI runs on the axiom homelab (CT 111).** Four of the five workflows execute on `[self-hosted, axiom]` runners managed in the `axiom-server` repo (`proxmox/ct111/`). Day-to-day this is invisible — pushes still trigger jobs, and Dokploy builds `main` on CT 112 (Vercel preview builds continue only until phase 8 decommission) — but two operational notes matter:
1. **PAT rotation.** The runners authenticate to GitHub via an org-scoped PAT stored on CT 111 at `/opt/appdata/gha-runner/.env` (key `GH_PAT`, scopes `admin:org`, `repo`, `workflow`). Rotate every 90 days. After updating the value on CT 111, run `./proxmox/scripts/sync.sh restart 111` to re-register all 4 runners. If the PAT lapses silently, new jobs fail registration immediately; check `./proxmox/scripts/sync.sh logs 111 gha-runner-1` for `Http response code: NotFound` to confirm.
2. **1-line revert path (D5) — when axiom is offline mid-PR-storm.** If CT 111 is down for maintenance, hardware swap, or any reason, and a hot fix needs CI to land, swap every `[self-hosted, axiom]` back to `ubuntu-latest`:

View file

@ -1,6 +1,10 @@
# Deck Hearth
A modern trading card game collection manager built with Next.js and Neon Database.
A modern trading card game collection manager built with Next.js and PostgreSQL.
> Repo history note: this project was formerly **TCG Vault** (`tcg-vault`);
> the GitHub repo is now [`stwl-labs/deckhearth`](https://github.com/stwl-labs/deckhearth).
> Local checkout folders named `tcg-vault` are fine.
## 🚀 Features
@ -15,19 +19,20 @@ A modern trading card game collection manager built with Next.js and Neon Databa
- **Frontend**: Next.js 16 (Pages router), React 18, JavaScript (TypeScript is a devDep only — see `AGENTS.md` Gotcha #9)
- **Backend**: Next.js API Routes
- **Database**: Neon PostgreSQL (serverless)
- **Database**: PostgreSQL 17 + pgvector on the axiom homelab (CT 102, `192.168.68.102:5432`), accessed via the tagged-template helper in `lib/sql.js` — see `docs/HOMELAB_DATABASE.md`
- **Authentication**: JWT with bcrypt (24-hour expiry; `lib/auth-secret.js` is the single source of truth for `JWT_SECRET`)
- **Rate limiting**: `@upstash/ratelimit` on `/api/auth/login` + `/api/auth/register` (5 attempts / 15 min per IP)
- **Testing**: Vitest (unit); Playwright queued
- **Styling**: Tailwind CSS
- **Deployment**: Vercel
- **Rate limiting**: `lib/rate-limit.js` — six named limiters (`auth`, `search`, `upload`, `generate`, `import`, `scan`) backed by `ioredis` + `rate-limiter-flexible` against homelab Redis (CT 102). Fails closed in production if `REDIS_URL` is unset; warn-and-no-op in dev
- **Testing**: Vitest (unit) + Playwright (`smoke` and `visual` projects)
- **Object storage**: MinIO on CT 102 (scan captures; S3-compatible via `@aws-sdk/client-s3`)
- **Styling**: Tailwind CSS + Liquid Glass design tokens (`docs/DESIGN_TOKENS.md`)
- **Deployment**: Dokploy on CT 112, public URL `https://deckhearth.stillwell.cloud` via Traefik — see `docs/DOKPLOY_DEPLOY.md`
## 📦 Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd tcg-vault
git clone https://github.com/stwl-labs/deckhearth.git
cd deckhearth
```
2. **Install dependencies**
@ -35,27 +40,32 @@ A modern trading card game collection manager built with Next.js and Neon Databa
npm install
```
3. **Set up environment variables**
```bash
cp .env.example .env.local
```
Update `.env.local` with your Neon database URL and a real JWT secret:
3. **Create `.env.local`** (there is no committed template — use the shape below)
```env
POSTGRES_URL="postgresql://your-username:your-password@your-host/your-database"
# Required — homelab Postgres (CT 102) or any Postgres 17 instance
POSTGRES_URL="postgresql://deckhearth:<password>@192.168.68.102:5432/deckhearth"
# Migrations (`npm run migrate`); on the homelab, same value as POSTGRES_URL
POSTGRES_URL_DIRECT="postgresql://deckhearth:<password>@192.168.68.102:5432/deckhearth"
JWT_SECRET="<generate with: openssl rand -hex 32>"
# Required for `npm run setup-db` — used once to hash the initial admin password.
# Set in .env.local for local dev, or as a CI secret if you run setup from CI.
ADMIN_INITIAL_PASSWORD="<generate with: openssl rand -base64 24>"
# Optional — exercise the rate limiter locally. Without them, `lib/rate-limit.js`
# warn-and-no-ops in dev. In production these are auto-provisioned by the
# Vercel Upstash Marketplace integration.
KV_REST_API_URL="https://<your-upstash-host>.upstash.io"
KV_REST_API_TOKEN="<your-upstash-rest-token>"
# Rate limiting (homelab Redis, CT 102 — DB index 5 in prod). Optional in dev —
# without it, `lib/rate-limit.js` warn-and-no-ops locally; production fails closed.
REDIS_URL="redis://:password@192.168.68.102:6379/5"
# Scan-capture uploads (MinIO on CT 102, S3-compatible — see lib/object-storage.js)
S3_ENDPOINT="http://192.168.68.102:9000"
S3_BUCKET="deckhearth"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
S3_PUBLIC_BASE_URL="https://cdn.stillwell.cloud/deckhearth"
S3_REGION="us-east-1"
```
`JWT_SECRET` is **required**`lib/auth-secret.js` throws at import time if it's unset.
`ADMIN_INITIAL_PASSWORD` is **required** for `npm run setup-db` — the script exits with code 1 if it's unset.
> Migrating data off the old Neon instance? `NEON_DATABASE_URL` is read once by
> `npm run migrate-neon-to-homelab`. See `docs/HOMELAB_DATABASE.md`.
4. **Set up the database**
```bash
npm run setup-db
@ -127,27 +137,35 @@ The application uses the following tables:
## 🚀 Deployment
This app is configured for deployment on Vercel:
Deck Hearth deploys to **Dokploy on CT 112** in the axiom homelab, fronted by
Traefik on CT 100 at `https://deckhearth.stillwell.cloud`. The full runbook —
Dokploy app settings, environment variables, Traefik route, and the n8n
catalog-sync cron that replaced Vercel Cron — lives in
[`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md).
1. **Connect your repository** to Vercel
2. **Set environment variables** in Vercel dashboard
3. **Deploy automatically** on push to main branch
> **Transition note:** the Vercel + Neon era is being decommissioned
> (`migrate-neon-to-homelab` convoy, phases 68). `vercel.json` and `.vercel/`
> remain in the tree until that decommission lands; CI already gates against
> the homelab deployment.
## 📁 Project Structure
```
tcg-vault/
deckhearth/ (formerly tcg-vault)
├── pages/ # Next.js pages and API routes
│ ├── api/ # API endpoints
│ │ ├── auth/ # Authentication routes
│ │ └── admin/ # Admin routes
│ ├── _app.js # App wrapper
│ └── index.js # Home page
├── lib/ # Utility libraries
│ └── database.js # Database adapter
├── scripts/ # Database setup scripts
├── public/ # Static assets
└── .env.local # Environment variables
├── components/ # React components (+ scanner/ subfamily)
├── lib/ # Utility libraries
│ └── sql.js # Canonical Postgres client (tagged templates)
├── migrations/ # node-pg-migrate migrations (source of truth for DDL)
├── scripts/ # Setup, import, and historical one-off jobs
├── docs/ # Deploy / DB / design-token runbooks
├── test/ + tests/ # Vitest unit specs; Playwright smoke + visual specs
└── .env.local # Environment variables (not committed)
```
## 🔐 First-time admin setup
@ -161,7 +179,7 @@ variable is unset or empty.
- **Local dev:** set `ADMIN_INITIAL_PASSWORD` in `.env.local` before running
`npm run setup-db`. Use `openssl rand -base64 24` (or any other strong
source) to generate the value.
- **CI / Vercel:** set `ADMIN_INITIAL_PASSWORD` as a project secret if setup
- **CI:** set `ADMIN_INITIAL_PASSWORD` as a repo secret if setup
ever runs from CI. The env var is **only** read by the seed script; runtime
auth uses the per-user password stored in the database.
- **Admin email:** the seed creates `admin@deckhearth.com`. Change the password

View file

@ -209,6 +209,14 @@ function NavigationContent({ user, router, onItemClick }) {
router.pathname === '/decks' ||
router.pathname.startsWith('/deck/'),
},
{
name: 'Designer',
href: '/my-designs',
icon: 'designer',
active:
router.pathname === '/designer' ||
router.pathname === '/my-designs',
},
{
name: 'Scanner',
href: '/scanner',
@ -245,6 +253,7 @@ function NavigationContent({ user, router, onItemClick }) {
items: [
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
{ name: 'Games', href: '/community/games', active: router.pathname.startsWith('/community/games') },
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
]
};
@ -318,6 +327,11 @@ function NavigationContent({ user, router, onItemClick }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
designer: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
};
return icons[iconName] || icons.grid;

View file

@ -0,0 +1,877 @@
/* eslint-disable @next/next/no-img-element -- Artwork/symbols come from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */
import { useEffect, useRef, useState } from 'react';
import { getFrame, getRarity } from './frames';
/** Natural render size of the card (5:7). Everything inside is px-based
* so screen preview and PNG export are pixel-identical. */
export const CARD_W = 420;
export const CARD_H = 588;
/**
* Default window zones for image-based frames, as fractions of the card.
* They mirror the framed layout's art window and text box positions.
*/
export const DEFAULT_LAYOUT = {
art: { x: 16 / CARD_W, y: 64 / CARD_H, w: 388 / CARD_W, h: 234 / CARD_H },
text: { x: 16 / CARD_W, y: 342 / CARD_H, w: 388 / CARD_W, h: 224 / CARD_H },
};
/** Standard vertical anchors (px) for text rows on image frames. */
export const IMAGE_FRAME_ROWS = {
title: { top: 22, height: 34 },
type: { top: 306, height: 28 },
};
/** Resolve the active palette: a custom frame's palette when linked,
* otherwise the starter frame's. */
export function resolvePalette(design) {
if (design.custom_frame?.palette) {
return design.custom_frame.palette;
}
return getFrame(design.frame_id).palette;
}
/** Optional background texture from the linked custom frame. */
function resolveTexture(design) {
return design.custom_frame?.texture_url || null;
}
/** Window zones for image frames: saved layout or the defaults. */
export function resolveLayout(design) {
const layout = design.custom_frame?.layout;
if (layout?.art && layout?.text) return layout;
return DEFAULT_LAYOUT;
}
/**
* Live card renderer for the designer. Renders at CARD_W x CARD_H and is
* scaled to fit its container by CardPreview below. Fully inline-styled
* so html-to-image can rasterize it 1:1 during PNG export.
*/
export default function CardFrame({ design, innerRef, symbols }) {
if (design.art_mode === 'fullart') {
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
}
if (design.custom_frame?.frame_image_url) {
return <ImageFrameCard design={design} innerRef={innerRef} symbols={symbols} />;
}
return <FramedCard design={design} innerRef={innerRef} symbols={symbols} />;
}
/* ─────────────────────────── image frame layout ────────────────────── */
/**
* Full-custom frame: the uploaded frame artwork is the card base; the
* artwork and text drop into the frame's saved window zones. Title,
* cost, type, and rarity render at standard anchors, colored by palette.
*/
function ImageFrameCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
const layout = resolveLayout(design);
const showPt = Boolean(design.power || design.toughness);
const zoneStyle = (zone) => ({
position: 'absolute',
left: `${(zone.x * 100).toFixed(3)}%`,
top: `${(zone.y * 100).toFixed(3)}%`,
width: `${(zone.w * 100).toFixed(3)}%`,
height: `${(zone.h * 100).toFixed(3)}%`,
overflow: 'hidden',
});
return (
<div
ref={innerRef}
style={{
width: CARD_W,
height: CARD_H,
position: 'relative',
backgroundColor: p.outer,
borderRadius: 18,
border: `6px solid ${p.border}`,
boxSizing: 'border-box',
overflow: 'hidden',
fontFamily: 'Georgia, "Times New Roman", serif',
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
userSelect: 'none',
}}
>
{/* Frame artwork base layer */}
<img
src={design.custom_frame.frame_image_url}
alt={`${design.custom_frame.name || 'Custom'} frame`}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'fill',
display: 'block',
}}
/>
{/* Artwork window */}
<div style={zoneStyle(layout.art)}>
{design.artwork_url ? (
<img
src={design.artwork_url}
alt={design.name || 'Card artwork'}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: p.artBacking,
opacity: 0.5,
}}
>
<span style={{ color: p.border, fontSize: 13, fontStyle: 'italic' }}>Artwork</span>
</div>
)}
</div>
{/* Title + cost */}
<div
style={{
position: 'absolute',
left: 26,
right: 26,
top: IMAGE_FRAME_ROWS.title.top,
height: IMAGE_FRAME_ROWS.title.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 17,
fontWeight: 700,
lineHeight: 1.15,
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.name || 'Untitled Card'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* Type line + rarity */}
<div
style={{
position: 'absolute',
left: 26,
right: 26,
top: IMAGE_FRAME_ROWS.type.top,
height: IMAGE_FRAME_ROWS.type.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 13,
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.card_type || '— Type —'}
</span>
<RarityBadge rarityId={design.rarity} size={18} />
</div>
{/* Text window */}
<div
style={{
...zoneStyle(layout.text),
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
}}
>
<TextBlocks
design={design}
color={p.text}
dividerColor={`${p.accent}55`}
symbols={symbols}
/>
{showPt && (
<div
style={{
marginTop: 'auto',
alignSelf: 'flex-end',
backgroundColor: p.titleBar,
color: p.titleText,
border: `1px solid ${p.border}`,
borderRadius: 999,
padding: '1px 14px',
fontWeight: 700,
fontSize: 14,
}}
>
{design.power || '0'} / {design.toughness || '0'}
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────── framed layout ─────────────────────────── */
function FramedCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
const texture = resolveTexture(design);
const showPt = Boolean(design.power || design.toughness);
return (
<div
ref={innerRef}
style={{
width: CARD_W,
height: CARD_H,
backgroundColor: p.outer,
backgroundImage: texture ? `url(${texture})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 18,
border: `6px solid ${p.border}`,
boxSizing: 'border-box',
padding: 16,
display: 'flex',
flexDirection: 'column',
fontFamily: 'Georgia, "Times New Roman", serif',
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
userSelect: 'none',
overflow: 'hidden',
}}
>
{/* Title bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
backgroundColor: p.titleBar,
border: `1px solid ${p.border}`,
borderRadius: 6,
padding: '6px 10px',
marginBottom: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 17,
fontWeight: 700,
lineHeight: 1.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.name || 'Untitled Card'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* Artwork window */}
<div
style={{
height: 234,
flexShrink: 0,
backgroundColor: p.artBacking,
border: `1px solid ${p.border}`,
borderRadius: 6,
overflow: 'hidden',
marginBottom: 8,
position: 'relative',
}}
>
{design.artwork_url ? (
<img
src={design.artwork_url}
alt={design.name || 'Card artwork'}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<ArtPlaceholder p={p} />
)}
</div>
{/* Type line + rarity badge (anchored inside the bar) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
backgroundColor: p.typeBar,
border: `1px solid ${p.border}`,
borderRadius: 6,
padding: '5px 10px',
marginBottom: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 13,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.card_type || '— Type —'}
</span>
<RarityBadge rarityId={design.rarity} size={18} />
</div>
{/* Text box */}
<TextBox design={design} p={p} showPt={showPt} symbols={symbols} />
</div>
);
}
/* ─────────────────────────── full-art layout ───────────────────────── */
function FullArtCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
const texture = resolveTexture(design);
const showPt = Boolean(design.power || design.toughness);
const hasArt = Boolean(design.artwork_url);
return (
<div
ref={innerRef}
style={{
width: CARD_W,
height: CARD_H,
position: 'relative',
borderRadius: 18,
border: `6px solid ${p.border}`,
boxSizing: 'border-box',
overflow: 'hidden',
backgroundColor: p.artBacking,
backgroundImage: texture ? `url(${texture})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
fontFamily: 'Georgia, "Times New Roman", serif',
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
userSelect: 'none',
}}
>
{/* Edge-to-edge artwork */}
{hasArt ? (
<img
src={design.artwork_url}
alt={design.name || 'Card artwork'}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<div style={{ position: 'absolute', inset: 0 }}>
<ArtPlaceholder p={p} full />
</div>
)}
{/* Top scrim: title + cost */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: '14px 14px 26px',
background: 'linear-gradient(to bottom, rgba(10,8,6,0.82) 0%, rgba(10,8,6,0.45) 70%, transparent 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<span
style={{
color: '#f7f2e6',
fontSize: 18,
fontWeight: 700,
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
lineHeight: 1.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.name || 'Untitled Card'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* Bottom scrim: type line, text, P/T */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: '30px 14px 12px',
background: 'linear-gradient(to top, rgba(10,8,6,0.88) 0%, rgba(10,8,6,0.62) 75%, transparent 100%)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
borderBottom: `1px solid ${p.accent}88`,
paddingBottom: 6,
}}
>
<span
style={{
color: '#f7f2e6',
fontSize: 13,
textShadow: '0 1px 3px rgba(0,0,0,0.9)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.card_type || '— Type —'}
</span>
<RarityBadge rarityId={design.rarity} size={18} />
</div>
<TextBlocks design={design} color="#f2ecdd" dividerColor={`${p.accent}99`} compact symbols={symbols} />
{showPt && (
<div
style={{
alignSelf: 'flex-end',
backgroundColor: 'rgba(10,8,6,0.75)',
color: '#f7f2e6',
border: `1px solid ${p.accent}`,
borderRadius: 999,
padding: '1px 14px',
fontWeight: 700,
fontSize: 14,
}}
>
{design.power || '0'} / {design.toughness || '0'}
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────── shared pieces ─────────────────────────── */
function ArtPlaceholder({ p, full = false }) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
opacity: full ? 0.35 : 0.55,
background: full
? `linear-gradient(160deg, ${p.titleBar}, ${p.artBacking} 70%)`
: 'transparent',
}}
>
<svg width="96" height="96" viewBox="0 0 24 24" fill="none" stroke={p.border} strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
<span style={{ color: p.border, fontSize: 13, fontStyle: 'italic', fontFamily: 'inherit' }}>
Upload artwork
</span>
</div>
);
}
function TextBox({ design, p, showPt, symbols }) {
const hasAny = design.rules_text || design.actions || design.flavor_quote;
return (
<div
style={{
flex: 1,
backgroundColor: p.textBox,
border: `1px solid ${p.border}`,
borderRadius: 6,
padding: '10px 12px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{hasAny ? (
<TextBlocks design={design} color={p.text} dividerColor={`${p.accent}55`} symbols={symbols} />
) : (
<p
style={{
margin: 'auto',
color: `${p.text}66`,
fontSize: 12.5,
fontStyle: 'italic',
}}
>
Description &amp; actions appear here
</p>
)}
{showPt && (
<div
style={{
marginTop: 'auto',
alignSelf: 'flex-end',
backgroundColor: p.titleBar,
color: p.titleText,
border: `1px solid ${p.border}`,
borderRadius: 999,
padding: '1px 14px',
fontWeight: 700,
fontSize: 14,
}}
>
{design.power || '0'} / {design.toughness || '0'}
</div>
)}
</div>
);
}
/**
* Ordered text content: description, actions, then the flavor quotation.
* Sections are separated by ornamental dividers; the quote renders in
* italics wrapped in decorative quotation marks. `{CODE}` tokens render
* inline as symbol icons when the user has defined them.
*/
function TextBlocks({ design, color, dividerColor, compact = false, symbols }) {
const fontSize = compact ? 11.5 : 12.5;
const blocks = [];
if (design.rules_text) {
blocks.push(
<RichText key="desc" text={design.rules_text} symbols={symbols}
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
);
}
if (design.actions) {
if (blocks.length > 0) blocks.push(<Divider key="d1" color={dividerColor} />);
blocks.push(
<RichText key="actions" text={design.actions} symbols={symbols}
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
);
}
if (design.flavor_quote) {
if (blocks.length > 0) blocks.push(<Divider key="d2" color={dividerColor} ornament />);
blocks.push(
<RichText
key="quote"
text={design.flavor_quote}
symbols={symbols}
style={{
margin: 0,
color,
fontSize,
lineHeight: 1.35,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
fontStyle: 'italic',
textAlign: 'center',
}}
prefix={<span style={{ opacity: 0.7, marginRight: 2, fontSize: fontSize + 3 }}>&ldquo;</span>}
suffix={<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>&rdquo;</span>}
/>
);
}
return <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>{blocks}</div>;
}
/** Renders text with `{CODE}` tokens replaced by inline symbol icons. */
export function RichText({ text, symbols, style, prefix, suffix }) {
const hasSymbols = symbols && Object.keys(symbols).length > 0;
const iconFor = (code) => {
if (!hasSymbols) return null;
return symbols[code] || symbols[code.toLowerCase()] || symbols[code.toUpperCase()] || null;
};
const parts = hasSymbols ? text.split(/(\{[^}]+\})/g) : [text];
return (
<p style={style}>
{prefix}
{parts.map((part, i) => {
const match = part.match(/^\{([^}]+)\}$/);
if (match) {
const icon = iconFor(match[1]);
if (icon) {
return (
<img
key={`${match[1]}-${i}`}
src={icon}
alt={match[1]}
style={{
height: '1.2em',
width: 'auto',
verticalAlign: '-0.22em',
display: 'inline-block',
margin: '0 1px',
}}
/>
);
}
}
return <span key={i}>{part}</span>;
})}
{suffix}
</p>
);
}
/** Thin rule; ornament adds a small diamond at center. */
function Divider({ color, ornament = false }) {
if (!ornament) {
return <div style={{ borderTop: `1px solid ${color}`, width: '100%' }} />;
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<div style={{ flex: 1, borderTop: `1px solid ${color}` }} />
<div
style={{
width: 6,
height: 6,
transform: 'rotate(45deg)',
backgroundColor: color,
}}
/>
<div style={{ flex: 1, borderTop: `1px solid ${color}` }} />
</div>
);
}
/**
* Rarity badge rendered inside the type bar: a distinct shape and color
* per rarity, vertically centered, right-aligned. Inline SVG keeps PNG
* export pixel-faithful.
*
* common circle
* uncommon diamond
* rare pentagon
* mythic star
*/
export function RarityBadge({ rarityId, size = 18 }) {
const rarity = getRarity(rarityId);
const s = size;
const stroke = 'rgba(0,0,0,0.55)';
const shine = 'rgba(255,255,255,0.5)';
const shapes = {
common: (
<circle cx={s / 2} cy={s / 2} r={s / 2 - 1.5} fill={rarity.color} stroke={stroke} strokeWidth="1.5" />
),
uncommon: (
<path
d={`M${s / 2} 1 L${s - 1} ${s / 2} L${s / 2} ${s - 1} L1 ${s / 2} Z`}
fill={rarity.color}
stroke={stroke}
strokeWidth="1.5"
strokeLinejoin="round"
/>
),
rare: (
<path
d={`M${s / 2} 1 L${s - 1} ${(s * 0.36).toFixed(1)} L${(s * 0.81).toFixed(1)} ${s - 1} L${(s * 0.19).toFixed(1)} ${s - 1} L1 ${(s * 0.36).toFixed(1)} Z`}
fill={rarity.color}
stroke={stroke}
strokeWidth="1.5"
strokeLinejoin="round"
/>
),
mythic: (
<path
d={starPath(s)}
fill={rarity.color}
stroke={stroke}
strokeWidth="1.5"
strokeLinejoin="round"
/>
),
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: s,
height: s,
flexShrink: 0,
filter: `drop-shadow(0 0 2px ${shine})`,
}}
title={rarity.name}
>
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
{shapes[rarity.id] || shapes.common}
</svg>
</span>
);
}
function starPath(s) {
const cx = s / 2;
const cy = s / 2 + s * 0.04;
const outer = s / 2 - 0.5;
const inner = outer * 0.42;
const points = [];
for (let i = 0; i < 10; i += 1) {
const r = i % 2 === 0 ? outer : inner;
const angle = (Math.PI / 5) * i - Math.PI / 2;
points.push(`${(cx + r * Math.cos(angle)).toFixed(2)} ${(cy + r * Math.sin(angle)).toFixed(2)}`);
}
return `M${points.join(' L')} Z`;
}
/**
* Scales CardFrame to fit the available width while preserving the
* natural 420x588 layout (transform keeps export coordinates intact).
*/
export function CardPreview({ design, innerRef, maxWidth = 420, symbols }) {
const containerRef = useRef(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const update = () => {
const available = Math.min(el.clientWidth, maxWidth);
setScale(Math.min(1, available / CARD_W));
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [maxWidth]);
return (
<div ref={containerRef} style={{ width: '100%', overflow: 'visible' }}>
<div
style={{
width: CARD_W * scale,
height: CARD_H * scale,
margin: '0 auto',
}}
>
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
<CardFrame design={design} innerRef={innerRef} symbols={symbols} />
</div>
</div>
</div>
);
}
/**
* Renders a mana cost string as pip circles. Accepts both scryfall
* braces ("{2}{R}{R}") and plain notation ("2RR"), plus arbitrary
* symbols for custom games. When `symbols` maps a token to an icon URL,
* the icon renders inside the pip instead of text.
*/
export function ManaPips({ cost, accent, symbols }) {
if (!cost || !cost.trim()) return null;
const tokens = /\{/.test(cost)
? (cost.match(/\{[^}]+\}/g) || []).map((t) => t.slice(1, -1))
: cost.split(/\s+/).flatMap((chunk) => chunk.split(''));
if (tokens.length === 0) return null;
const iconFor = (token) => {
if (!symbols) return null;
return (
symbols[token] ||
symbols[token.toLowerCase()] ||
symbols[token.toUpperCase()] ||
null
);
};
return (
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
{tokens.slice(0, 8).map((token, i) => {
const icon = iconFor(token);
return (
<span
key={`${token}-${i}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: '#0e0c08',
border: `1.5px solid ${accent}`,
color: '#f4efe2',
fontSize: 12,
fontWeight: 700,
fontFamily: 'Georgia, serif',
overflow: 'hidden',
}}
>
{icon ? (
<img
src={icon}
alt={token}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
token
)}
</span>
);
})}
</span>
);
}

View file

@ -0,0 +1,161 @@
/* eslint-disable @next/next/no-img-element -- frame image comes from the MinIO CDN; next/image is out of scope for the designer canvas. */
import { useRef } from 'react';
import { CARD_W, CARD_H, DEFAULT_LAYOUT } from './CardFrame';
const ZONE_COLORS = {
art: { fill: 'rgba(110, 168, 220, 0.18)', border: '#6ea8dc' },
text: { fill: 'rgba(143, 188, 111, 0.18)', border: '#8fbc6f' },
};
const HANDLE = 14;
/**
* Interactive zone editor for image-based frames. Renders the uploaded
* frame artwork at natural card size with draggable/resizable overlays
* for the art and text windows. Zones are fractions of the card.
*
* Drag inside a zone to move it; drag the bottom-right handle to resize.
*/
export default function ZoneEditor({ frameImageUrl, layout, onChange }) {
const dragState = useRef(null);
const clampZone = (zone) => {
const w = Math.min(Math.max(zone.w, 0.05), 1);
const h = Math.min(Math.max(zone.h, 0.05), 1);
return {
w,
h,
x: Math.min(Math.max(zone.x, 0), 1 - w),
y: Math.min(Math.max(zone.y, 0), 1 - h),
};
};
const startDrag = (zoneKey, mode) => (event) => {
event.preventDefault();
event.target.setPointerCapture?.(event.pointerId);
dragState.current = {
zoneKey,
mode,
startX: event.clientX,
startY: event.clientY,
origin: layout[zoneKey],
};
};
const onPointerMove = (event) => {
const state = dragState.current;
if (!state) return;
const dx = (event.clientX - state.startX) / CARD_W;
const dy = (event.clientY - state.startY) / CARD_H;
const o = state.origin;
let next;
if (state.mode === 'resize') {
next = clampZone({ x: o.x, y: o.y, w: o.w + dx, h: o.h + dy });
} else {
next = clampZone({ x: o.x + dx, y: o.y + dy, w: o.w, h: o.h });
}
onChange({ ...layout, [state.zoneKey]: next });
};
const endDrag = () => {
dragState.current = null;
};
const renderZone = (zoneKey) => {
const zone = layout[zoneKey] || DEFAULT_LAYOUT[zoneKey];
const colors = ZONE_COLORS[zoneKey];
return (
<div
key={zoneKey}
onPointerDown={startDrag(zoneKey, 'move')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
left: `${(zone.x * 100).toFixed(3)}%`,
top: `${(zone.y * 100).toFixed(3)}%`,
width: `${(zone.w * 100).toFixed(3)}%`,
height: `${(zone.h * 100).toFixed(3)}%`,
backgroundColor: colors.fill,
border: `2px dashed ${colors.border}`,
cursor: 'move',
touchAction: 'none',
userSelect: 'none',
}}
>
<span
style={{
position: 'absolute',
top: 4,
left: 6,
fontSize: 11,
fontWeight: 700,
color: colors.border,
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{zoneKey === 'art' ? 'ARTWORK' : 'TEXT'}
</span>
{/* Resize handle */}
<div
onPointerDown={startDrag(zoneKey, 'resize')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
right: -HANDLE / 2,
bottom: -HANDLE / 2,
width: HANDLE,
height: HANDLE,
backgroundColor: colors.border,
border: '2px solid #fff',
borderRadius: 3,
cursor: 'nwse-resize',
touchAction: 'none',
}}
/>
</div>
);
};
return (
<div style={{ overflow: 'auto', maxWidth: '100%' }}>
<div
style={{
position: 'relative',
width: CARD_W,
height: CARD_H,
backgroundColor: '#1a1a22',
backgroundImage: frameImageUrl ? `url(${frameImageUrl})` : undefined,
backgroundSize: '100% 100%',
borderRadius: 12,
overflow: 'hidden',
flexShrink: 0,
}}
>
{!frameImageUrl && (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
fontSize: 13,
fontStyle: 'italic',
}}
>
Upload a frame image to position its windows
</div>
)}
{renderZone('art')}
{renderZone('text')}
</div>
</div>
);
}

View file

@ -0,0 +1,86 @@
/**
* Starter frame definitions for the card designer.
* Colors are concrete values (no CSS vars) so the rendered frame
* rasterizes faithfully during PNG export.
*/
export const FRAMES = [
{
id: 'classic',
name: 'Ember Classic',
description: 'Warm creature frame with gold trim',
palette: {
outer: '#2b1d12',
border: '#c9a227',
titleBar: '#513a20',
typeBar: '#5d4426',
textBox: '#e8dcc3',
artBacking: '#1a1108',
text: '#241a0e',
titleText: '#f5ead1',
accent: '#c9a227',
},
},
{
id: 'sorcery',
name: 'Azure Sorcery',
description: 'Cool spell frame with arcane blue',
palette: {
outer: '#101b2b',
border: '#6ea8dc',
titleBar: '#1d3a57',
typeBar: '#24466a',
textBox: '#dfe9f4',
artBacking: '#0a1220',
text: '#152232',
titleText: '#e3eefb',
accent: '#6ea8dc',
},
},
{
id: 'verdant',
name: 'Verdant Wilds',
description: 'Nature frame with deep green growth',
palette: {
outer: '#14210f',
border: '#8fbc6f',
titleBar: '#2c4420',
typeBar: '#36512a',
textBox: '#e4ecd8',
artBacking: '#0c1408',
text: '#1c2913',
titleText: '#eaf3de',
accent: '#8fbc6f',
},
},
{
id: 'void',
name: 'Void Artifact',
description: 'Neutral dark frame for anything',
palette: {
outer: '#17161a',
border: '#9d93b8',
titleBar: '#2e2b36',
typeBar: '#383442',
textBox: '#e6e3ee',
artBacking: '#0e0d11',
text: '#211f27',
titleText: '#ece9f4',
accent: '#9d93b8',
},
},
];
export const RARITIES = [
{ id: 'common', name: 'Common', color: '#9ca3af' },
{ id: 'uncommon', name: 'Uncommon', color: '#a8b6c8' },
{ id: 'rare', name: 'Rare', color: '#d4af37' },
{ id: 'mythic', name: 'Mythic', color: '#e0662f' },
];
export function getFrame(frameId) {
return FRAMES.find((f) => f.id === frameId) || FRAMES[0];
}
export function getRarity(rarityId) {
return RARITIES.find((r) => r.id === rarityId) || RARITIES[0];
}

View file

@ -6,7 +6,7 @@ Homelab deploy: Postgres + Redis + MinIO on CT 102, app on Dokploy, public URL v
- [x] `deckhearth` Postgres — `provision-deckhearth.sh`
- [x] MinIO bucket — `provision-deckhearth-minio.sh` (axiom-server)
- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (optional)
- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (one-time; run once then archive Neon. Script reads `NEON_DATABASE_URL` (source) and `POSTGRES_URL_DIRECT` (target). Requires `pg_dump` + `pg_restore` on PATH. If starting fresh with no Neon data, skip this step.)
## 2. Dokploy application
@ -76,9 +76,10 @@ Weekly: `0 6 * * 1` (Mondays 06:00).
## 6. Decommission Vercel
1. Point DNS / AdGuard rewrite for `deckhearth.stillwell.cloud` at Traefik (already `*.stillwell.cloud` → CT 100).
2. Disable Vercel project auto-deploy or delete project after Coolify/Dokploy is stable.
3. Remove unused env vars from Vercel dashboard (Neon, Blob, Upstash).
1. [ ] **Run the data copy first**`npm run migrate-neon-to-homelab` (see § 1). If there is no Neon data to migrate, skip to step 2.
2. [ ] **Verify DNS** — confirm `deckhearth.stillwell.cloud` resolves to Traefik (CT 100). Already configured for `*.stillwell.cloud` → CT 100; verify in your DNS provider or AdGuard.
3. [ ] **Delete the Vercel project** at vercel.com. After confirming the Dokploy deployment is stable and serving traffic, delete the Vercel project to stop billing and auto-deploys.
4. [ ] **Remove old env vars from Vercel dashboard** — Neon connection string, Blob storage, Upstash/Redis vars are no longer used by the app.
## 7. Local dev

156
docs/SCANNER_DEBUG_MODE.md Normal file
View file

@ -0,0 +1,156 @@
# Scanner Debug Mode
Real-time performance instrumentation for the card scanner pipeline. Use this to diagnose slow identification, rate-limit issues, or layer-specific bottlenecks on iOS Chrome, Mac webcam, or any device.
## Activation
**Method 1: Browser console (temporary)**
```javascript
window.__SCANNER_DEBUG = true
```
**Method 2: localStorage (persists across reloads)**
```javascript
localStorage.setItem('SCANNER_DEBUG', 'true')
```
Then navigate to `/scanner` or reload the page.
## What Gets Logged
Every scanner operation logs timestamped messages with emoji prefixes for quick visual scanning:
### Shutter Press & Overall Timing
```
[Scanner Debug 21:45:32.123] 🎯 Shutter pressed (tracker-42, attempt 1)
[Scanner Debug 21:45:37.456] ✅ Card verified successfully → 5333ms total
```
### Layer 0: pgvector Visual Similarity
```
[Scanner Debug 21:45:32.150] 🔍 Layer 0 (pgvector visual) started
[Scanner Debug 21:45:32.270] ⬆️ Layer 0 escalating → 120ms { reason: 'low confidence' }
```
or
```
[Scanner Debug 21:45:32.270] ✅ Layer 0 resolved → 120ms { card: 'Lightning Bolt', matches: undefined }
```
### Layer 1: Tesseract OCR + pg_trgm
```
[Scanner Debug 21:45:32.280] 📝 Layer 1 (Tesseract OCR + pg_trgm) started
[Scanner Debug 21:45:33.130] 🔤 OCR completed → 850ms { nameText: 'Lightning Bolt', confidence: 88 }
[Scanner Debug 21:45:33.280] ✅ Layer 1 resolved → 1000ms { card: 'Lightning Bolt', matches: 3 }
```
### Layer 2: Vision API (Gemini/OpenAI)
```
[Scanner Debug 21:45:33.290] 🤖 Layer 2 (Vision API) call started
[Scanner Debug 21:45:37.490] ✅ Vision API success → 4200ms { card: 'Lightning Bolt', needsUserSelection: false }
```
### Rate Limit / Cooldown
```
[Scanner Debug 21:45:37.500] 🚫 Rate limit hit (15/min) — cooldown until 21:46:37 → 10ms
```
or
```
[Scanner Debug 21:45:38.000] ⏸️ Vision cooldown active — retry in 59s
```
### Errors
```
[Scanner Debug 21:45:32.500] ⚠️ Layer 0 failed → 220ms { rateLimited: true }
[Scanner Debug 21:45:33.500] ❌ Verification failed (no outcome) → 1000ms
[Scanner Debug 21:45:34.500] 💥 Verification exception → 1200ms { error: 'Network request failed' }
```
## Reading the Output
**Total scan time breakdown:**
- **Layer 0 (pgvector):** Typically 80200ms. If this escalates, L1 runs next.
- **Layer 1 OCR:** Tesseract runs in-browser; expect 6001200ms. If name extraction fails or confidence is low, escalates to L2.
- **Layer 2 Vision API:** Network call to `/api/scan/identify` (Gemini/OpenAI). Typically 28 seconds depending on network + API latency.
**Rate limit (15/min):** The scanner allows 15 vision API calls per minute per user (Redis key `deckhearth:scan:{userId}`). If you hit this, you'll see the cooldown message with the exact retry timestamp.
## Common Patterns
### Fast path (Layer 0 hit)
```
🎯 Shutter pressed
🔍 Layer 0 started
✅ Layer 0 resolved → 120ms
🎉 Pipeline complete (L0) → 125ms total
✅ Card verified successfully → 130ms total
```
### OCR path (Layer 0 miss, Layer 1 hit)
```
🎯 Shutter pressed
🔍 Layer 0 started
⬆️ Layer 0 escalating → 120ms
📝 Layer 1 started
🔤 OCR completed → 850ms
✅ Layer 1 resolved → 1000ms
🎉 Pipeline complete (L1) → 1020ms total
✅ Card verified successfully → 1025ms total
```
### Vision API path (both layers escalate)
```
🎯 Shutter pressed
🔍 Layer 0 started
⬆️ Layer 0 escalating → 120ms
📝 Layer 1 started
🔤 OCR completed → 850ms
⬆️ Layer 1 escalating → 1000ms
🤖 Layer 2 (Vision API) started
✅ Vision API success → 4200ms
🎉 Pipeline complete (L2) → 5320ms total
✅ Card verified successfully → 5325ms total
```
### Rate limit hit
```
🎯 Shutter pressed (attempt 16)
🔍 Layer 0 started
⬆️ Layer 0 escalating → 110ms
📝 Layer 1 started
⬆️ Layer 1 escalating → 950ms
🤖 Layer 2 (Vision API) started
🚫 Vision API rate limited → 80ms
🚫 Pipeline halted (rate limited) → 1140ms total
🚫 Rate limit hit (15/min) — cooldown until 21:46:32 → 1145ms
```
## iOS Chrome Specific Issues
If you see **no logs at all** on iOS Chrome:
1. Open Safari on macOS, connect your iPhone via USB
2. Develop → [Your iPhone] → [deckhearth tab]
3. The Safari Web Inspector console will show the debug logs
If **Layer 0/1 succeed but L2 times out**:
- iOS Chrome network throttling may be active (Settings → Safari → Advanced → Experimental Features)
- Vision API may be slow on cellular — test on Wi-Fi
If **OCR step shows `nameText: ""` repeatedly**:
- Camera capture may be producing black frames on iOS — check canvas output in the inspector
## Deactivation
```javascript
delete window.__SCANNER_DEBUG
localStorage.removeItem('SCANNER_DEBUG')
```
Then reload the page.
## Implementation
Debug logs are injected at:
- `lib/scanner-card-identify.js` — Layer 0/1/2 network calls + pipeline orchestration
- `lib/use-scanner-identification.js` — Shutter press + verification wrapper
The `isDebugMode()` check is inlined in every `debugLog()` call, so there's zero runtime overhead when debug mode is off.

View file

@ -0,0 +1,33 @@
/**
* Shared field normalization for custom card designs.
* Single source of truth so the create and update routes never drift.
*/
export function pickDesignFields(body = {}) {
const str = (v) => (typeof v === 'string' ? v.trim() : null);
const int = (v) => {
const n = parseInt(v, 10);
return Number.isInteger(n) ? n : null;
};
const artMode = str(body.art_mode) || str(body.artMode) || 'framed';
const gameTarget = str(body.game_target) || str(body.gameTarget) || 'custom';
return {
name: str(body.name),
manaCost: str(body.mana_cost) || str(body.manaCost),
cardType: str(body.card_type) || str(body.cardType),
rarity: str(body.rarity),
rulesText: str(body.rules_text) || str(body.description),
actions: str(body.actions),
flavorQuote: str(body.flavor_quote) || str(body.flavorQuote),
power: str(body.power),
toughness: str(body.toughness),
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
gameTarget,
customGameId: int(body.custom_game_id) || int(body.customGameId),
customFrameId: int(body.custom_frame_id) || int(body.customFrameId),
};
}

24
lib/designer-games.js Normal file
View file

@ -0,0 +1,24 @@
/**
* Game systems available for card targeting. Values match the codes the
* catalog already uses for imported cards ('MTG', 'Pokemon', 'Lorcana')
* so designer cards filter correctly alongside imports.
*/
export const EXISTING_GAMES = [
{ value: 'MTG', label: 'Magic: The Gathering' },
{ value: 'Pokemon', label: 'Pokémon' },
{ value: 'Lorcana', label: 'Disney Lorcana' },
{ value: 'Star Wars Unlimited', label: 'Star Wars: Unlimited' },
{ value: 'Flesh and Blood', label: 'Flesh and Blood' },
{ value: 'One Piece', label: 'One Piece' },
{ value: 'Sorcery', label: 'Sorcery: Contested Realm' },
{ value: 'Grand Archive', label: 'Grand Archive' },
];
export function isExistingGame(value) {
return EXISTING_GAMES.some((g) => g.value === value);
}
export function gameLabel(value) {
const match = EXISTING_GAMES.find((g) => g.value === value);
return match ? match.label : value;
}

85
lib/frame-palette.js Normal file
View file

@ -0,0 +1,85 @@
/**
* Default window zones for image-based frames, as fractions of the card.
* They mirror the framed layout's art window and text box positions.
*/
export const DEFAULT_LAYOUT = {
art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 },
text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 },
};
/**
* Palette contract for card frames. Starter and custom frames share the
* same slots; CardFrame resolves a palette from these keys.
*/
export const PALETTE_SLOTS = [
{ key: 'outer', label: 'Card body' },
{ key: 'border', label: 'Frame border' },
{ key: 'titleBar', label: 'Title bar' },
{ key: 'typeBar', label: 'Type bar' },
{ key: 'textBox', label: 'Text box' },
{ key: 'artBacking', label: 'Art backing' },
{ key: 'text', label: 'Body text' },
{ key: 'titleText', label: 'Title text' },
{ key: 'accent', label: 'Accent' },
];
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/**
* Validate/normalize a palette object. Returns { palette } with all nine
* slots as lowercase hex, or { error } when a slot is missing/invalid.
*/
export function validatePalette(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { error: 'Palette must be an object' };
}
const palette = {};
for (const { key } of PALETTE_SLOTS) {
const value = input[key];
if (typeof value !== 'string' || !HEX_RE.test(value.trim())) {
return { error: `Invalid or missing color for "${key}"` };
}
palette[key] = value.trim().toLowerCase();
}
return { palette };
}
/**
* Validate/normalize frame layout zones (art + text windows as 0-1
* fractions of the card). Returns { layout } or { error }.
*/
export function validateLayout(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { error: 'Layout must be an object' };
}
const layout = {};
for (const zone of ['art', 'text']) {
const z = input[zone];
if (!z || typeof z !== 'object') {
return { error: `Layout zone "${zone}" is required` };
}
const { x, y, w, h } = z;
for (const [key, value] of Object.entries({ x, y, w, h })) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return { error: `Layout zone "${zone}" has invalid ${key}` };
}
}
if (w <= 0 || h <= 0) {
return { error: `Layout zone "${zone}" must have positive size` };
}
if (x < 0 || y < 0 || x + w > 1.001 || y + h > 1.001) {
return { error: `Layout zone "${zone}" extends past the card` };
}
layout[zone] = {
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.min(w, 1 - Math.max(0, x)),
h: Math.min(h, 1 - Math.max(0, y)),
};
}
return { layout };
}

View file

@ -1,5 +1,24 @@
import { isValidCardQuad, warpCardCaptureFromVideo } from './scanner-card-warp.js';
/** Debug mode flag — set via localStorage or window global */
function isDebugMode() {
if (typeof window === 'undefined') return false;
return (
window.__SCANNER_DEBUG === true ||
localStorage.getItem('SCANNER_DEBUG') === 'true'
);
}
function debugLog(emoji, message, data) {
if (!isDebugMode()) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, 12);
if (data !== undefined) {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`, data);
} else {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`);
}
}
/** Default margin (px) around tracked bounds when cropping a card capture. */
export const CAPTURE_MARGIN_PX = 20;
@ -266,15 +285,23 @@ export async function fetchIdentifyByImage(imageData, authHeaders, game) {
* Layer 0: visual kNN against precomputed catalog embeddings.
*/
export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
const startMs = Date.now();
debugLog('🔍', 'Layer 0 (pgvector visual) started');
const l0 = await fetchIdentifyByImage(imageData, authHeaders, game);
const durationMs = Date.now() - startMs;
if (!l0.ok) {
debugLog('⚠️', `Layer 0 failed → ${durationMs}ms`, { rateLimited: l0.rateLimited });
return { handled: false };
}
if (l0.result.escalate) {
debugLog('⬆️', `Layer 0 escalating → ${durationMs}ms`, { reason: l0.result.escalateReason || 'low confidence' });
return { handled: false };
}
debugLog('✅', `Layer 0 resolved → ${durationMs}ms`, { card: l0.result.card?.name, matches: l0.result.matches?.length });
return {
handled: true,
outcome: resolveIdentifyOutcome(l0.result),
@ -282,22 +309,30 @@ export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
}
export async function fetchVisionIdentify(imageData, authHeaders) {
const startMs = Date.now();
debugLog('🤖', 'Layer 2 (Vision API) call started');
const response = await fetch('/api/scan/identify', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ imageData }),
});
const durationMs = Date.now() - startMs;
if (response.status === 429) {
debugLog('🚫', `Vision API rate limited → ${durationMs}ms`);
return { ok: false, rateLimited: true };
}
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
debugLog('❌', `Vision API failed (${response.status}) → ${durationMs}ms`, { error: errBody.error });
throw new Error(errBody.error || `Scan identify failed: ${response.status}`);
}
const result = await response.json();
debugLog('✅', `Vision API success → ${durationMs}ms`, { card: result.card?.name, needsUserSelection: result.needsUserSelection });
return { ok: true, result };
}
@ -306,10 +341,18 @@ export async function fetchVisionIdentify(imageData, authHeaders) {
* Returns { handled: true, outcome } when L1 resolves without escalation.
*/
export async function tryLayer1TextIdentify(imageData, authHeaders) {
const startMs = Date.now();
debugLog('📝', 'Layer 1 (Tesseract OCR + pg_trgm) started');
const { recognizeCardFields } = await import('./ocr-worker.js');
const ocrStartMs = Date.now();
const ocr = await recognizeCardFields(imageData);
const ocrDurationMs = Date.now() - ocrStartMs;
debugLog('🔤', `OCR completed → ${ocrDurationMs}ms`, { nameText: ocr.nameText, confidence: ocr.nameConfidence });
if (ocr.nameText.length < 3) {
debugLog('⚠️', `Layer 1 skipped (name too short: "${ocr.nameText}") → ${Date.now() - startMs}ms`);
return { handled: false };
}
@ -320,10 +363,14 @@ export async function tryLayer1TextIdentify(imageData, authHeaders) {
authHeaders,
});
const durationMs = Date.now() - startMs;
if (!l1.ok || l1.result.escalate) {
debugLog('⬆️', `Layer 1 escalating → ${durationMs}ms`, { escalate: l1.result?.escalate });
return { handled: false };
}
debugLog('✅', `Layer 1 resolved → ${durationMs}ms`, { card: l1.result.card?.name, matches: l1.result.matches?.length });
return {
handled: true,
outcome: resolveIdentifyOutcome(l1.result),
@ -340,6 +387,9 @@ export async function identifyTrackedCardCapture({
authHeaders,
visionCooldownUntilMs = 0,
}) {
const pipelineStartMs = Date.now();
debugLog('🎯', `Card verification started (tracker-${cardTracker.id})`);
const imageData = captureCardRegionFromVideo(
video,
canvas,
@ -351,30 +401,38 @@ export async function identifyTrackedCardCapture({
try {
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
if (l0.handled) {
debugLog('🎉', `Pipeline complete (L0) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, ...l0 };
}
} catch (l0Error) {
console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error);
debugLog('⚠️', 'Layer 0 exception', { error: l0Error.message });
}
try {
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
if (l1.handled) {
debugLog('🎉', `Pipeline complete (L1) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, ...l1 };
}
} catch (l1Error) {
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
debugLog('⚠️', 'Layer 1 exception', { error: l1Error.message });
}
if (Date.now() < visionCooldownUntilMs) {
const cooldownSecondsRemaining = Math.ceil((visionCooldownUntilMs - Date.now()) / 1000);
debugLog('⏸️', `Vision cooldown active — retry in ${cooldownSecondsRemaining}s`);
return { imageData, retry: true };
}
const vision = await fetchVisionIdentify(imageData, authHeaders);
if (vision.rateLimited) {
debugLog('🚫', `Pipeline halted (rate limited) → ${Date.now() - pipelineStartMs}ms total`);
return { imageData, rateLimited: true };
}
debugLog('🎉', `Pipeline complete (L2) → ${Date.now() - pipelineStartMs}ms total`);
return {
imageData,
handled: true,

View file

@ -13,6 +13,25 @@ import {
fetchVisionIdentify,
} from './scanner-card-identify.js';
/** Debug mode flag — set via localStorage or window global */
function isDebugMode() {
if (typeof window === 'undefined') return false;
return (
window.__SCANNER_DEBUG === true ||
localStorage.getItem('SCANNER_DEBUG') === 'true'
);
}
function debugLog(emoji, message, data) {
if (!isDebugMode()) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, 12);
if (data !== undefined) {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`, data);
} else {
console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`);
}
}
/**
* Card identification, disambiguation, and review-submission flow for the scanner.
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
@ -298,6 +317,9 @@ export function useScannerIdentification({
if (disambiguation) return;
if (activeVerificationRef.current >= 1) return;
const verifyStartMs = Date.now();
debugLog('🎯', `Shutter pressed (tracker-${cardTracker.id}, attempt ${cardTracker.scanAttempts + 1})`);
activeVerificationRef.current += 1;
cardTracker.status = 'verifying';
setIsIdentifying(true);
@ -314,12 +336,15 @@ export function useScannerIdentification({
});
if (identification.retry) {
debugLog('⏸️', `Verification skipped (cooldown active) → ${Date.now() - verifyStartMs}ms`);
cardTracker.status = 'detecting';
return;
}
if (identification.rateLimited) {
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
const cooldownUntil = new Date(visionCooldownUntilRef.current).toISOString().split('T')[1].slice(0, 8);
debugLog('🚫', `Rate limit hit (15/min) — cooldown until ${cooldownUntil}${Date.now() - verifyStartMs}ms`);
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
@ -328,13 +353,16 @@ export function useScannerIdentification({
if (identification.handled && identification.outcome) {
cardTracker.status = 'confirmed';
debugLog('✅', `Card verified successfully → ${Date.now() - verifyStartMs}ms total`, { outcome: identification.outcome.type });
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
} else {
debugLog('❌', `Verification failed (no outcome) → ${Date.now() - verifyStartMs}ms`);
cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
}
} catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error);
debugLog('💥', `Verification exception → ${Date.now() - verifyStartMs}ms`, { error: error.message });
cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
reportScannerError(error.message || 'Scan failed');

View file

@ -0,0 +1,43 @@
/**
* Custom card designs (card designer feature).
*
* One row per user-designed card. Designer-specific fields live here
* (frame, artwork, actions); on save we also upsert a matching catalog
* row in `cards` (game='Custom') + a `user_cards` row so designed cards
* appear in My Cards, lists, and decks through the normal joins.
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS custom_cards (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
mana_cost VARCHAR(50),
card_type VARCHAR(255),
rarity VARCHAR(50),
rules_text TEXT,
actions TEXT,
power VARCHAR(10),
toughness VARCHAR(10),
frame_id VARCHAR(50) DEFAULT 'classic',
artwork_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_user_id
ON custom_cards(user_id)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_card_id
ON custom_cards(card_id)
`);
};
export const down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS custom_cards CASCADE`);
};

View file

@ -0,0 +1,37 @@
/**
* Phase 1 designer upgrades:
* - art_mode: 'framed' (classic layout) | 'fullart' (edge-to-edge art,
* text on scrims)
* - flavor_quote: optional quotation rendered with decorative dividers
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE custom_cards
ADD COLUMN IF NOT EXISTS art_mode VARCHAR(20) NOT NULL DEFAULT 'framed',
ADD COLUMN IF NOT EXISTS flavor_quote TEXT
`);
pgm.sql(`
ALTER TABLE custom_cards
DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
`);
pgm.sql(`
ALTER TABLE custom_cards
ADD CONSTRAINT chk_custom_cards_art_mode
CHECK (art_mode IN ('framed', 'fullart'))
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_cards
DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
`);
pgm.sql(`
ALTER TABLE custom_cards
DROP COLUMN IF EXISTS art_mode,
DROP COLUMN IF EXISTS flavor_quote
`);
};

View file

@ -0,0 +1,45 @@
/**
* Phase 2 game targeting + custom game spaces.
*
* custom_games: private, per-user named game systems. Cards, frames, and
* (later) symbols can belong to one.
*
* custom_cards.game_target: 'custom' (default, standalone designs) or the
* code of an existing system ('MTG', 'Pokemon', 'Lorcana', ...).
* custom_cards.custom_game_id: set when the design belongs to a user's
* custom game.
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS custom_games (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_custom_games_user_name UNIQUE (user_id, name)
)
`);
pgm.sql(`
ALTER TABLE custom_cards
ADD COLUMN IF NOT EXISTS game_target VARCHAR(50) NOT NULL DEFAULT 'custom',
ADD COLUMN IF NOT EXISTS custom_game_id INTEGER REFERENCES custom_games(id) ON DELETE SET NULL
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_custom_game_id
ON custom_cards(custom_game_id)
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_cards
DROP COLUMN IF EXISTS custom_game_id,
DROP COLUMN IF EXISTS game_target
`);
pgm.sql(`DROP TABLE IF EXISTS custom_games CASCADE`);
};

View file

@ -0,0 +1,56 @@
/**
* Phase 3 user-created frames and cost symbols.
*
* custom_frames: private per-user frames. `palette` holds the full slot
* map (outer, border, titleBar, typeBar, textBox, artBacking, text,
* titleText, accent) as JSONB so new slots stay additive.
*
* custom_symbols: user-uploaded cost icons keyed by a short code used in
* mana cost strings ({F}, {2}{F}...).
*
* custom_cards.custom_frame_id: set when a design uses a custom frame;
* NULL means the starter frame in `frame_id` applies.
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS custom_frames (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
palette JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_custom_frames_user_name UNIQUE (user_id, name)
)
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS custom_symbols (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code VARCHAR(10) NOT NULL,
image_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_custom_symbols_user_code UNIQUE (user_id, code)
)
`);
pgm.sql(`
ALTER TABLE custom_cards
ADD COLUMN IF NOT EXISTS custom_frame_id INTEGER REFERENCES custom_frames(id) ON DELETE SET NULL
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_custom_frame_id
ON custom_cards(custom_frame_id)
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_cards DROP COLUMN IF EXISTS custom_frame_id
`);
pgm.sql(`DROP TABLE IF EXISTS custom_symbols CASCADE`);
pgm.sql(`DROP TABLE IF EXISTS custom_frames CASCADE`);
};

View file

@ -0,0 +1,33 @@
/**
* Designer follow-ups:
* - custom_frames.texture_url: optional background texture image for
* custom frames (renders behind the card's panels).
* - custom_games.is_public: share a custom game space to the community
* section (read-only).
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE custom_frames
ADD COLUMN IF NOT EXISTS texture_url TEXT
`);
pgm.sql(`
ALTER TABLE custom_games
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_games_public
ON custom_games(is_public)
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_games DROP COLUMN IF EXISTS is_public
`);
pgm.sql(`
ALTER TABLE custom_frames DROP COLUMN IF EXISTS texture_url
`);
};

View file

@ -0,0 +1,23 @@
/**
* Image-based custom frames:
* - frame_image_url: full-card frame artwork (PNG with transparent
* windows for art/text) rendered as the card base layer.
* - layout: art/text window zones as 0-1 fractions of the card
* ({ art: {x,y,w,h}, text: {x,y,w,h} }) so windows align with the
* uploaded image regardless of render size.
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE custom_frames
ADD COLUMN IF NOT EXISTS frame_image_url TEXT,
ADD COLUMN IF NOT EXISTS layout JSONB
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_frames
DROP COLUMN IF EXISTS layout,
DROP COLUMN IF EXISTS frame_image_url
`);
};

7
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@neondatabase/serverless": "^1.1.0",
"bcryptjs": "^3.0.2",
"dotenv": "^17.2.1",
"html-to-image": "^1.11.13",
"ioredis": "^5.7.0",
"jsonwebtoken": "^9.0.2",
"next": "^16.2.6",
@ -6159,6 +6160,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",

View file

@ -30,6 +30,7 @@
"@neondatabase/serverless": "^1.1.0",
"bcryptjs": "^3.0.2",
"dotenv": "^17.2.1",
"html-to-image": "^1.11.13",
"ioredis": "^5.7.0",
"jsonwebtoken": "^9.0.2",
"next": "^16.2.6",

View file

@ -0,0 +1,98 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
import { syncCatalogCard, ensureOwnedRow, resolveCatalogGame } from './index.js';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const designId = parseInt(req.query.id, 10);
if (!Number.isInteger(designId)) {
return res.status(400).json({ error: 'Invalid design id' });
}
const found = await sql`
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette, f.texture_url AS frame_texture,
f.frame_image_url AS frame_image, f.layout AS frame_layout
FROM custom_cards c
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
WHERE c.id = ${designId} AND c.user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Design not found' });
}
const { frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...row } = found.rows[0];
const existing = {
...row,
custom_frame:
frame_name != null
? {
id: row.custom_frame_id,
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};
if (req.method === 'GET') {
return res.status(200).json({ design: existing });
}
if (req.method === 'PUT') {
const f = pickDesignFields(req.body);
if (!f.name) {
return res.status(400).json({ error: 'Name is required' });
}
const updated = await sql`
UPDATE custom_cards SET
name = ${f.name}, mana_cost = ${f.manaCost},
card_type = ${f.cardType}, rarity = ${f.rarity},
rules_text = ${f.rulesText}, actions = ${f.actions},
flavor_quote = ${f.flavorQuote},
power = ${f.power}, toughness = ${f.toughness},
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
art_mode = ${f.artMode},
game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId},
custom_frame_id = ${f.customFrameId},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${designId}
RETURNING *
`;
const design = updated.rows[0];
const catalogGame = await resolveCatalogGame(user.userId, f);
const cardId = await syncCatalogCard(design, f, catalogGame);
if (cardId) {
await ensureOwnedRow(user.userId, cardId);
if (!design.card_id) {
const linked = await sql`
UPDATE custom_cards SET card_id = ${cardId}
WHERE id = ${designId} RETURNING *
`;
return res.status(200).json({ design: linked.rows[0] });
}
}
return res.status(200).json({ design });
}
if (req.method === 'DELETE') {
// The catalog twin stays (it may already live in lists/decks);
// removing the design row simply detaches future edits from it.
await sql`DELETE FROM custom_cards WHERE id = ${designId}`;
return res.status(200).json({ message: 'Design deleted' });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom card API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,174 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
import { isExistingGame } from '../../../lib/designer-games.js';
const CUSTOM_GAME = 'Custom';
const CUSTOM_SET = 'Designs';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const result = await sql`
SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
c.frame_id, c.artwork_url, c.art_mode,
c.game_target, c.custom_game_id, g.name AS custom_game_name,
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout,
c.created_at, c.updated_at
FROM custom_cards c
LEFT JOIN custom_games g ON g.id = c.custom_game_id
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
WHERE c.user_id = ${user.userId}
ORDER BY c.updated_at DESC
`;
const designs = result.rows.map((row) => ({
...row,
custom_frame:
row.frame_pk != null
? {
id: row.frame_pk,
name: row.frame_name,
palette: row.frame_palette,
texture_url: row.frame_texture,
frame_image_url: row.frame_image,
layout: row.frame_layout,
}
: null,
}));
return res.status(200).json({
designs: designs.map(
({ frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest }) => rest
),
});
}
if (req.method === 'POST') {
const design = await createDesign(user.userId, req.body);
if (!design) {
return res.status(400).json({ error: 'Name is required' });
}
return res.status(201).json({ design });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom cards API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
async function createDesign(userId, body) {
const f = pickDesignFields(body);
if (!f.name) return null;
const catalogGame = await resolveCatalogGame(userId, f);
const inserted = await sql`
INSERT INTO custom_cards
(user_id, name, mana_cost, card_type, rarity, rules_text,
actions, flavor_quote, power, toughness, frame_id, artwork_url,
art_mode, game_target, custom_game_id, custom_frame_id)
VALUES
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode},
${f.gameTarget}, ${f.customGameId}, ${f.customFrameId})
RETURNING *
`;
const design = inserted.rows[0];
// Mirror into the shared catalog so the design shows up in My Cards,
// lists, and decks through the normal card joins.
const cardId = await syncCatalogCard(design, f, catalogGame);
if (cardId) {
await ensureOwnedRow(userId, cardId);
const linked = await sql`
UPDATE custom_cards SET card_id = ${cardId} WHERE id = ${design.id}
RETURNING *
`;
return linked.rows[0];
}
return design;
}
/**
* Resolve the catalog `game` value for a design: an existing system code,
* the owning custom game's name, or the generic 'Custom' bucket.
*/
export async function resolveCatalogGame(userId, fields) {
if (fields.gameTarget && isExistingGame(fields.gameTarget)) {
return fields.gameTarget;
}
if (fields.customGameId) {
const rows = await sql`
SELECT name FROM custom_games
WHERE id = ${fields.customGameId} AND user_id = ${userId}
`;
if (rows.rows.length > 0) {
// cards.game is VARCHAR(50) — custom game names cap at 100, so clip.
return rows.rows[0].name.slice(0, 50);
}
}
return CUSTOM_GAME;
}
/** Create/update the catalog twin of a custom design; returns card id. */
export async function syncCatalogCard(design, fields, catalogGame) {
const values = {
name: fields.name,
setName: CUSTOM_SET,
setCode: 'DSGN',
rarity: fields.rarity,
game: catalogGame || CUSTOM_GAME,
manaCost: fields.manaCost,
cardType: fields.cardType,
oracleText:
[fields.actions, fields.rulesText, fields.flavorQuote]
.filter(Boolean)
.join('\n\n') || null,
imageUrl: fields.artworkUrl,
};
if (design.card_id) {
const updated = await sql`
UPDATE cards SET
name = ${values.name}, set_name = ${values.setName},
set_code = ${values.setCode}, rarity = ${values.rarity},
mana_cost = ${values.manaCost}, card_type = ${values.cardType},
oracle_text = ${values.oracleText}, image_url = ${values.imageUrl},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${design.card_id}
RETURNING id
`;
if (updated.rows.length > 0) return updated.rows[0].id;
}
const inserted = await sql`
INSERT INTO cards (name, set_name, set_code, rarity, game,
mana_cost, card_type, oracle_text, image_url)
VALUES (${values.name}, ${values.setName}, ${values.setCode},
${values.rarity}, ${values.game}, ${values.manaCost},
${values.cardType}, ${values.oracleText}, ${values.imageUrl})
RETURNING id
`;
return inserted.rows[0].id;
}
/** Idempotently give the designer one copy of their own creation. */
export async function ensureOwnedRow(userId, cardId) {
await sql`
INSERT INTO user_cards (user_id, card_id, quantity)
VALUES (${userId}, ${cardId}, 1)
ON CONFLICT (user_id, card_id, is_foil)
DO NOTHING
`;
}

View file

@ -0,0 +1,122 @@
import { put } from '../../../lib/object-storage.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb',
},
},
};
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
}
const formData = await parseMultipartFormData(req);
const file = formData.artwork;
if (!file) {
return res.status(400).json({ error: 'No artwork file provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a JPEG, PNG, or WebP image.',
});
}
if (file.size > 5 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 5MB' });
}
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1];
const filename = `card-art/${user.userId}-${Date.now()}.${extension}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
return res.status(200).json({ artwork_url: blob.url });
} catch (error) {
console.error('Artwork upload API error:', error);
return res.status(500).json({ error: 'Failed to upload artwork' });
}
}
async function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const parts = buffer.toString('binary').split(`--${boundary}`);
const formData = {};
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (nameMatch) {
const fieldName = nameMatch[1];
const headerEndIndex = part.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
const content = part.substring(headerEndIndex + 4);
const contentBuffer = Buffer.from(content, 'binary');
if (filenameMatch && contentTypeMatch) {
formData[fieldName] = {
originalName: filenameMatch[1],
type: contentTypeMatch[1].trim(),
buffer: contentBuffer.slice(0, -2),
size: contentBuffer.length - 2,
};
} else {
formData[fieldName] = content.trim();
}
}
}
}
}
resolve(formData);
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}

View file

@ -0,0 +1,87 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { validatePalette, validateLayout, DEFAULT_LAYOUT } from '../../../lib/frame-palette.js';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const frameId = parseInt(req.query.id, 10);
if (!Number.isInteger(frameId)) {
return res.status(400).json({ error: 'Invalid frame id' });
}
const found = await sql`
SELECT * FROM custom_frames
WHERE id = ${frameId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Frame not found' });
}
if (req.method === 'GET') {
return res.status(200).json({ frame: found.rows[0] });
}
if (req.method === 'PUT') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name || name.length > 100) {
return res.status(400).json({ error: 'Frame name is required (100 characters max)' });
}
const { palette, error } = validatePalette(req.body?.palette);
if (error) {
return res.status(400).json({ error });
}
const clash = await sql`
SELECT id FROM custom_frames
WHERE user_id = ${user.userId}
AND lower(name) = ${name.toLowerCase()}
AND id <> ${frameId}
`;
if (clash.rows.length > 0) {
return res.status(409).json({ error: 'You already have a frame with that name' });
}
const textureUrl =
req.body?.texture_url === null || req.body?.texture_url === ''
? null
: typeof req.body?.texture_url === 'string'
? req.body.texture_url.trim()
: found.rows[0].texture_url;
const layoutInput = req.body?.layout === undefined ? found.rows[0].layout : req.body.layout;
const { layout, error: layoutError } = validateLayout(layoutInput || {});
if (layoutError) {
return res.status(400).json({ error: layoutError });
}
const updated = await sql`
UPDATE custom_frames SET
name = ${name}, palette = ${sql.json(palette)},
texture_url = ${textureUrl},
layout = ${sql.json(layout)},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
RETURNING id, name, palette, texture_url, frame_image_url, layout, created_at, updated_at
`;
return res.status(200).json({ frame: updated.rows[0] });
}
if (req.method === 'DELETE') {
// Designs using this frame fall back to their starter frame_id
// (custom_frame_id drops to NULL via FK).
await sql`DELETE FROM custom_frames WHERE id = ${frameId}`;
return res.status(200).json({ message: 'Frame deleted' });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom frame API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,164 @@
import { put, del } from '../../../../lib/object-storage.js';
import { sql } from '../../../../lib/sql.js';
import { getUserFromRequest } from '../../../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../../../lib/rate-limit.js';
export const config = {
api: {
bodyParser: {
sizeLimit: '8mb',
},
},
};
const ALLOWED_TYPES = ['image/png', 'image/webp'];
/**
* POST upload the full-card frame artwork for a custom frame
* (PNG/WebP with transparent art + text windows)
* DELETE remove the frame image
*/
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const frameId = parseInt(req.query.id, 10);
if (!Number.isInteger(frameId)) {
return res.status(400).json({ error: 'Invalid frame id' });
}
const found = await sql`
SELECT id, frame_image_url FROM custom_frames
WHERE id = ${frameId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Frame not found' });
}
const frame = found.rows[0];
const deleteStoredImage = async () => {
if (!frame.frame_image_url) return;
try {
await del(frame.frame_image_url);
} catch (blobError) {
console.warn('Failed to delete old frame image:', blobError);
}
};
if (req.method === 'DELETE') {
await deleteStoredImage();
await sql`
UPDATE custom_frames SET frame_image_url = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ frame_image_url: null });
}
if (req.method === 'POST') {
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
}
const formData = await parseMultipartFormData(req);
const file = formData.image;
if (!file) {
return res.status(400).json({ error: 'No frame image provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a PNG or WebP image (transparency required).',
});
}
if (file.size > 8 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 8MB' });
}
await deleteStoredImage();
const extension = file.type.split('/')[1];
const filename = `frame-images/${user.userId}-${frameId}-${Date.now()}.${extension}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
await sql`
UPDATE custom_frames SET frame_image_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ frame_image_url: blob.url });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Frame image API error:', error);
return res.status(500).json({ error: 'Failed to handle frame image' });
}
}
async function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const parts = buffer.toString('binary').split(`--${boundary}`);
const formData = {};
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (nameMatch) {
const fieldName = nameMatch[1];
const headerEndIndex = part.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
const content = part.substring(headerEndIndex + 4);
const contentBuffer = Buffer.from(content, 'binary');
if (filenameMatch && contentTypeMatch) {
formData[fieldName] = {
originalName: filenameMatch[1],
type: contentTypeMatch[1].trim(),
buffer: contentBuffer.slice(0, -2),
size: contentBuffer.length - 2,
};
} else {
formData[fieldName] = content.trim();
}
}
}
}
}
resolve(formData);
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}

View file

@ -0,0 +1,163 @@
import { put, del } from '../../../../lib/object-storage.js';
import { sql } from '../../../../lib/sql.js';
import { getUserFromRequest } from '../../../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../../../lib/rate-limit.js';
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb',
},
},
};
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
/**
* POST upload a background texture for a custom frame
* DELETE remove the frame's texture
*/
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const frameId = parseInt(req.query.id, 10);
if (!Number.isInteger(frameId)) {
return res.status(400).json({ error: 'Invalid frame id' });
}
const found = await sql`
SELECT id, texture_url FROM custom_frames
WHERE id = ${frameId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Frame not found' });
}
const frame = found.rows[0];
const deleteStoredTexture = async () => {
if (!frame.texture_url) return;
try {
await del(frame.texture_url);
} catch (blobError) {
console.warn('Failed to delete old frame texture:', blobError);
}
};
if (req.method === 'DELETE') {
await deleteStoredTexture();
await sql`
UPDATE custom_frames SET texture_url = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ texture_url: null });
}
if (req.method === 'POST') {
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
}
const formData = await parseMultipartFormData(req);
const file = formData.texture;
if (!file) {
return res.status(400).json({ error: 'No texture file provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a JPEG, PNG, or WebP image.',
});
}
if (file.size > 5 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 5MB' });
}
await deleteStoredTexture();
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1];
const filename = `frame-textures/${user.userId}-${frameId}-${Date.now()}.${extension}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
await sql`
UPDATE custom_frames SET texture_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ texture_url: blob.url });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Frame texture API error:', error);
return res.status(500).json({ error: 'Failed to handle frame texture' });
}
}
async function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const parts = buffer.toString('binary').split(`--${boundary}`);
const formData = {};
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (nameMatch) {
const fieldName = nameMatch[1];
const headerEndIndex = part.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
const content = part.substring(headerEndIndex + 4);
const contentBuffer = Buffer.from(content, 'binary');
if (filenameMatch && contentTypeMatch) {
formData[fieldName] = {
originalName: filenameMatch[1],
type: contentTypeMatch[1].trim(),
buffer: contentBuffer.slice(0, -2),
size: contentBuffer.length - 2,
};
} else {
formData[fieldName] = content.trim();
}
}
}
}
}
resolve(formData);
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}

View file

@ -0,0 +1,66 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { validatePalette, validateLayout, DEFAULT_LAYOUT } from '../../../lib/frame-palette.js';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const result = await sql`
SELECT id, name, palette, texture_url, frame_image_url, layout,
created_at, updated_at
FROM custom_frames
WHERE user_id = ${user.userId}
ORDER BY name ASC
`;
return res.status(200).json({ frames: result.rows });
}
if (req.method === 'POST') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name || name.length > 100) {
return res.status(400).json({ error: 'Frame name is required (100 characters max)' });
}
const { palette, error } = validatePalette(req.body?.palette);
if (error) {
return res.status(400).json({ error });
}
const layoutInput = req.body?.layout || DEFAULT_LAYOUT;
const { layout, error: layoutError } = validateLayout(layoutInput);
if (layoutError) {
return res.status(400).json({ error: layoutError });
}
const textureUrl =
typeof req.body?.texture_url === 'string' && req.body.texture_url.trim()
? req.body.texture_url.trim()
: null;
const clash = await sql`
SELECT id FROM custom_frames
WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()}
`;
if (clash.rows.length > 0) {
return res.status(409).json({ error: 'You already have a frame with that name' });
}
const inserted = await sql`
INSERT INTO custom_frames (user_id, name, palette, texture_url, layout)
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl}, ${sql.json(layout)})
RETURNING id, name, palette, texture_url, frame_image_url, layout, created_at, updated_at
`;
return res.status(201).json({ frame: inserted.rows[0] });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom frames API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,103 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const gameId = parseInt(req.query.id, 10);
if (!Number.isInteger(gameId)) {
return res.status(400).json({ error: 'Invalid game id' });
}
const found = await sql`
SELECT * FROM custom_games
WHERE id = ${gameId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Game not found' });
}
const game = found.rows[0];
if (req.method === 'GET') {
const cards = await sql`
SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
c.frame_id, c.artwork_url, c.art_mode,
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout,
c.created_at, c.updated_at
FROM custom_cards c
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
WHERE c.custom_game_id = ${gameId}
ORDER BY c.updated_at DESC
`;
const designs = cards.rows.map((row) => {
const { frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest } = row;
return {
...rest,
custom_frame:
frame_pk != null
? {
id: frame_pk,
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};
});
return res.status(200).json({ game, designs });
}
if (req.method === 'PUT') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const description =
typeof req.body?.description === 'string' ? req.body.description.trim() : null;
const isPublic =
typeof req.body?.is_public === 'boolean' ? req.body.is_public : game.is_public;
if (!name) {
return res.status(400).json({ error: 'Game name is required' });
}
const clash = await sql`
SELECT id FROM custom_games
WHERE user_id = ${user.userId}
AND lower(name) = ${name.toLowerCase()}
AND id <> ${gameId}
`;
if (clash.rows.length > 0) {
return res.status(409).json({ error: 'You already have a game with that name' });
}
const updated = await sql`
UPDATE custom_games SET
name = ${name}, description = ${description}, is_public = ${isPublic},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${gameId}
RETURNING *
`;
return res.status(200).json({ game: updated.rows[0] });
}
if (req.method === 'DELETE') {
// Designs keep existing (custom_game_id drops to NULL via FK);
// their catalog twins stay in the collection untouched.
await sql`DELETE FROM custom_games WHERE id = ${gameId}`;
return res.status(200).json({ message: 'Game deleted' });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom game API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,58 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const result = await sql`
SELECT g.id, g.name, g.description, g.is_public, g.created_at, g.updated_at,
COUNT(c.id) AS card_count
FROM custom_games g
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
WHERE g.user_id = ${user.userId}
GROUP BY g.id
ORDER BY g.name ASC
`;
return res.status(200).json({ games: result.rows });
}
if (req.method === 'POST') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const description =
typeof req.body?.description === 'string' ? req.body.description.trim() : null;
if (!name) {
return res.status(400).json({ error: 'Game name is required' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Game name must be 100 characters or fewer' });
}
const existing = await sql`
SELECT id FROM custom_games
WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()}
`;
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'You already have a game with that name' });
}
const inserted = await sql`
INSERT INTO custom_games (user_id, name, description)
VALUES (${user.userId}, ${name}, ${description})
RETURNING id, name, description, created_at, updated_at
`;
const game = { ...inserted.rows[0], card_count: 0 };
return res.status(201).json({ game });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom games API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,41 @@
import { del } from '../../../lib/object-storage.js';
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const symbolId = parseInt(req.query.id, 10);
if (!Number.isInteger(symbolId)) {
return res.status(400).json({ error: 'Invalid symbol id' });
}
if (req.method !== 'DELETE') {
return res.status(405).json({ error: 'Method not allowed' });
}
const found = await sql`
SELECT id, image_url FROM custom_symbols
WHERE id = ${symbolId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Symbol not found' });
}
try {
await del(found.rows[0].image_url);
} catch (blobError) {
console.warn('Failed to delete symbol image:', blobError);
}
await sql`DELETE FROM custom_symbols WHERE id = ${symbolId}`;
return res.status(200).json({ message: 'Symbol deleted' });
} catch (error) {
console.error('Custom symbol API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,158 @@
import { put, del } from '../../../lib/object-storage.js';
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
export const config = {
api: {
bodyParser: {
sizeLimit: '2mb',
},
},
};
const ALLOWED_TYPES = ['image/png', 'image/webp', 'image/svg+xml'];
const CODE_RE = /^[A-Za-z0-9]{1,10}$/;
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const result = await sql`
SELECT id, code, image_url, created_at
FROM custom_symbols
WHERE user_id = ${user.userId}
ORDER BY code ASC
`;
return res.status(200).json({ symbols: result.rows });
}
if (req.method === 'POST') {
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
}
const formData = await parseMultipartFormData(req);
const code = (formData.code || '').trim();
const file = formData.image;
if (!CODE_RE.test(code)) {
return res.status(400).json({
error: 'Symbol code must be 1-10 letters or numbers (e.g. F, E, 10)',
});
}
if (!file) {
return res.status(400).json({ error: 'No symbol image provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a PNG, WebP, or SVG image.',
});
}
if (file.size > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 2MB' });
}
// Replace any existing symbol with the same code.
const existing = await sql`
SELECT id, image_url FROM custom_symbols
WHERE user_id = ${user.userId} AND code = ${code}
`;
if (existing.rows.length > 0) {
try {
await del(existing.rows[0].image_url);
} catch (blobError) {
console.warn('Failed to delete old symbol image:', blobError);
}
}
const ext = file.type === 'image/svg+xml' ? 'svg' : file.type.split('/')[1];
const filename = `symbols/${user.userId}-${code.toLowerCase()}-${Date.now()}.${ext}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
const saved = await sql`
INSERT INTO custom_symbols (user_id, code, image_url)
VALUES (${user.userId}, ${code}, ${blob.url})
ON CONFLICT (user_id, code)
DO UPDATE SET image_url = ${blob.url}, created_at = CURRENT_TIMESTAMP
RETURNING id, code, image_url, created_at
`;
return res.status(existing.rows.length > 0 ? 200 : 201).json({ symbol: saved.rows[0] });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom symbols API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
async function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const parts = buffer.toString('binary').split(`--${boundary}`);
const formData = {};
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (nameMatch) {
const fieldName = nameMatch[1];
const headerEndIndex = part.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
const content = part.substring(headerEndIndex + 4);
const contentBuffer = Buffer.from(content, 'binary');
if (filenameMatch && contentTypeMatch) {
formData[fieldName] = {
originalName: filenameMatch[1],
type: contentTypeMatch[1].trim(),
buffer: contentBuffer.slice(0, -2),
size: contentBuffer.length - 2,
};
} else {
formData[fieldName] = content.trim();
}
}
}
}
}
resolve(formData);
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}

View file

@ -0,0 +1,66 @@
import { sql } from '../../../../lib/sql.js';
/**
* Public detail for one community-shared custom game plus its designs.
* No auth read-only; 404 unless the game is marked public.
*/
export default async function handler(req, res) {
try {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const gameId = parseInt(req.query.id, 10);
if (!Number.isInteger(gameId)) {
return res.status(400).json({ error: 'Invalid game id' });
}
const found = await sql`
SELECT g.id, g.name, g.description, g.updated_at,
COALESCE(NULLIF(u.username, ''), split_part(u.email, '@', 1)) AS author
FROM custom_games g
JOIN users u ON u.id = g.user_id
WHERE g.id = ${gameId} AND g.is_public = true
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Game not found' });
}
const cards = await sql`
SELECT c.id, c.name, c.mana_cost, c.card_type, c.rarity,
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
c.frame_id, c.artwork_url, c.art_mode,
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout
FROM custom_cards c
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
WHERE c.custom_game_id = ${gameId}
ORDER BY c.updated_at DESC
`;
const designs = cards.rows.map((row) => {
const { frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest } = row;
return {
...rest,
custom_frame:
frame_pk != null
? {
id: frame_pk,
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};
});
return res.status(200).json({ game: found.rows[0], designs });
} catch (error) {
console.error('Public game API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,30 @@
import { sql } from '../../../../lib/sql.js';
/**
* Public listing of community-shared custom games. No auth read-only.
*/
export default async function handler(req, res) {
try {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const result = await sql`
SELECT g.id, g.name, g.description, g.updated_at,
COUNT(c.id) AS card_count,
COALESCE(NULLIF(u.username, ''), split_part(u.email, '@', 1)) AS author
FROM custom_games g
JOIN users u ON u.id = g.user_id
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
WHERE g.is_public = true
GROUP BY g.id, u.username, u.email
ORDER BY card_count DESC, g.updated_at DESC
LIMIT 100
`;
return res.status(200).json({ games: result.rows });
} catch (error) {
console.error('Public games API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,111 @@
/* eslint-disable @next/next/no-img-element -- Artwork/symbols come from the MinIO CDN; next/image is out of scope for the designer canvas. */
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../../components/Layout';
import CardPreview from '../../../components/designer/CardFrame';
import { Button } from '../../../components/ui';
import { useAuth } from '../../../lib/use-auth';
/** Read-only public view of a community-shared custom game. */
export default function PublicGameSpace() {
const router = useRouter();
const { id } = router.query;
const { user } = useAuth();
const [game, setGame] = useState(null);
const [designs, setDesigns] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) return undefined;
const loadGame = async () => {
setLoading(true);
try {
const response = await fetch(`/api/public/games/${id}`);
const data = await response.json();
if (response.ok) {
setGame(data.game);
setDesigns(data.designs);
} else {
setError(data.error || 'Game not found.');
}
} catch {
setError('Failed to load the game.');
} finally {
setLoading(false);
}
};
loadGame();
return undefined;
}, [id]);
const symbolsMap = {};
// Symbol icons render from per-user libraries; public views fall back
// to text pips unless codes are embedded later.
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
if (error && !game) {
return (
<Layout user={user}>
<div className="p-6 max-w-2xl mx-auto text-center py-20">
<h1 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{error}</h1>
<Button variant="primary" onClick={() => router.push('/community/games')}>
Back to Community Games
</Button>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
<div className="pt-2">
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{game.name}
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
{game.description || 'Custom game system'} · by {game.author} · {designs.length} card{designs.length === 1 ? '' : 's'}
</p>
</div>
{designs.length === 0 ? (
<div className="text-center py-16">
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
No cards in this game yet
</h3>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
{designs.map((design) => (
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
<CardPreview design={design} maxWidth={280} symbols={symbolsMap} />
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{design.name}
</p>
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
</p>
</div>
</div>
))}
</div>
)}
</div>
</Layout>
);
}

View file

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../../../components/Layout';
import { useAuth } from '../../../lib/use-auth';
export default function CommunityGames() {
const router = useRouter();
const { user } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const loadGames = async () => {
try {
const response = await fetch('/api/public/games');
if (response.ok) {
const data = await response.json();
setGames(data.games);
} else {
setError('Failed to load community games.');
}
} catch {
setError('Failed to load community games.');
} finally {
setLoading(false);
}
};
loadGames();
}, []);
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
<div className="pt-2">
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
Community Games
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
Custom game systems shared by the community browse their cards.
</p>
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-16 w-16 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
) : games.length === 0 ? (
<div className="text-center py-16">
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No shared games yet
</h3>
<p style={{ color: 'var(--text-secondary)' }}>
{user
? 'Share one of your custom games from its game space to see it here.'
: 'Sign in and share a custom game to see it here.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{games.map((game) => (
<div
key={game.id}
className="glass-panel rounded-2xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg"
onClick={() => router.push(`/community/games/${game.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && router.push(`/community/games/${game.id}`)}
>
<h3 className="text-lg font-bold mb-1 truncate" style={{ color: 'var(--text-primary)' }}>
{game.name}
</h3>
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{game.description || 'No description'}
</p>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold" style={{ color: 'var(--accent-ember)' }}>
{game.card_count} card{game.card_count === 1 ? '' : 's'}
</span>
<span className="text-xs truncate ml-2" style={{ color: 'var(--text-secondary)' }}>
by {game.author}
</span>
</div>
</div>
))}
</div>
)}
{user && (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Managing your own games? Head to{' '}
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>.
</p>
)}
</div>
</Layout>
);
}

1192
pages/designer.js Normal file

File diff suppressed because it is too large Load diff

329
pages/designer/print.js Normal file
View file

@ -0,0 +1,329 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import CardFrame, { CARD_W, CARD_H } from '../../components/designer/CardFrame';
import { Button } from '../../components/ui';
import { useAuth } from '../../lib/use-auth';
// US Letter at 300 DPI. Standard TCG card = 63 x 88 mm -> 744 x 1040 px.
const SHEET_W = 2550;
const SHEET_H = 3300;
const CARD_PRINT_W = 744;
const CARD_PRINT_H = 1040;
const CARD_SCALE = CARD_PRINT_W / CARD_W; // 420 -> 744
const LAYOUTS = {
'3x3': { cols: 3, rows: 3, label: '3 × 3 (9 cards)' },
'2x2': { cols: 2, rows: 2, label: '2 × 2 (4 cards)' },
};
export default function PrintSheet() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [designs, setDesigns] = useState([]);
const [symbolsMap, setSymbolsMap] = useState({});
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(new Set());
const [layout, setLayout] = useState('3x3');
const [exporting, setExporting] = useState(false);
const [error, setError] = useState(null);
const sheetRef = useRef(null);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return undefined;
const load = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const [cardsRes, symbolsRes] = await Promise.all([
fetch('/api/custom-cards', { headers }),
fetch('/api/custom-symbols', { headers }),
]);
if (cardsRes.ok) {
const data = await cardsRes.json();
setDesigns(data.designs);
// Preselect via ?ids=1,2,3 — otherwise everything.
const idsParam = router.query.ids;
if (typeof idsParam === 'string' && idsParam.length > 0) {
const ids = new Set(
idsParam.split(',').map((n) => parseInt(n, 10)).filter(Number.isInteger)
);
setSelected(new Set(data.designs.filter((d) => ids.has(d.id)).map((d) => d.id)));
} else {
setSelected(new Set(data.designs.map((d) => d.id)));
}
} else {
setError('Failed to load your designs.');
}
if (symbolsRes.ok) {
const data = await symbolsRes.json();
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
}
} catch {
setError('Failed to load your designs.');
} finally {
setLoading(false);
}
};
load();
return undefined;
}, [user, router.query.ids]);
const toggle = (id) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedDesigns = useMemo(
() => designs.filter((d) => selected.has(d.id)),
[designs, selected]
);
// Preview scale so the full sheet fits the preview pane.
const [previewScale, setPreviewScale] = useState(0.25);
const previewWrapRef = useRef(null);
const updatePreviewScale = useCallback(() => {
const el = previewWrapRef.current;
if (el) setPreviewScale(Math.min(0.35, el.clientWidth / SHEET_W));
}, []);
useEffect(() => {
updatePreviewScale();
const observer = new ResizeObserver(updatePreviewScale);
if (previewWrapRef.current) observer.observe(previewWrapRef.current);
return () => observer.disconnect();
}, [updatePreviewScale]);
const handleExport = async () => {
if (!sheetRef.current || selectedDesigns.length === 0) return;
setExporting(true);
setError(null);
try {
const { toPng } = await import('html-to-image');
const dataUrl = await toPng(sheetRef.current, {
width: SHEET_W,
height: SHEET_H,
pixelRatio: 1,
cacheBust: true,
});
const link = document.createElement('a');
link.download = `deckhearth-print-sheet-${selectedDesigns.length}cards.png`;
link.href = dataUrl;
link.click();
} catch {
setError('Export failed. Please try again.');
} finally {
setExporting(false);
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
const { cols, rows } = LAYOUTS[layout];
const perSheet = cols * rows;
const slots = Array.from({ length: perSheet }, (_, i) => selectedDesigns[i] || null);
const sheetCount = Math.max(1, Math.ceil(selectedDesigns.length / perSheet));
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
Print Sheet
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
Arrange cards on a US Letter sheet (63 × 88 mm cards, cut guides included).
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => router.push('/my-designs')}>Back</Button>
<Button
variant="primary"
onClick={handleExport}
disabled={exporting || selectedDesigns.length === 0}
>
{exporting ? 'Exporting…' : 'Download Sheet PNG'}
</Button>
</div>
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,380px)_minmax(0,1fr)] gap-6">
{/* Controls + card picker */}
<div className="space-y-5 min-w-0">
<section className="glass-panel rounded-2xl p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
Layout
</h2>
<div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
{Object.entries(LAYOUTS).map(([key, cfg]) => (
<button
key={key}
type="button"
onClick={() => setLayout(key)}
className="flex-1 px-4 py-2 text-xs font-semibold transition-all duration-200"
style={{
backgroundColor: layout === key ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: layout === key ? 'white' : 'var(--text-secondary)',
}}
aria-pressed={layout === key}
>
{cfg.label}
</button>
))}
</div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{selectedDesigns.length} card{selectedDesigns.length === 1 ? '' : 's'} selected
{selectedDesigns.length > perSheet
? ` · ${sheetCount} sheets (export one at a time)`
: ''}
</p>
</section>
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
Cards
</h2>
{designs.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
No designs yet create some in the{' '}
<Link href="/designer" style={{ color: 'var(--accent-ember)' }}>designer</Link> first.
</p>
) : (
<div className="space-y-2 max-h-[480px] overflow-y-auto pr-1">
{designs.map((design) => (
<label
key={design.id}
className="flex items-center gap-3 rounded-xl px-3 py-2 cursor-pointer transition-all duration-150 hover:shadow-md"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<input
type="checkbox"
checked={selected.has(design.id)}
onChange={() => toggle(design.id)}
className="w-4 h-4"
/>
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{design.name}
</span>
</label>
))}
</div>
)}
</section>
</div>
{/* Sheet preview */}
<div>
<div className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
Sheet Preview
</h2>
<div ref={previewWrapRef} style={{ width: '100%', overflow: 'hidden' }}>
<div
style={{
width: SHEET_W * previewScale,
height: SHEET_H * previewScale,
margin: '0 auto',
}}
>
<div style={{ transform: `scale(${previewScale})`, transformOrigin: 'top left' }}>
<PrintSheetSurface
slots={slots}
cols={cols}
rows={rows}
sheetRef={sheetRef}
symbolsMap={symbolsMap}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}
/**
* The actual 2550x3300 print surface. Cards render at natural 420x588
* then scale up 1.771x to true print size; dashed guides mark cut lines.
*/
function PrintSheetSurface({ slots, cols, rows, sheetRef, symbolsMap }) {
const marginX = (SHEET_W - cols * CARD_PRINT_W) / 2;
const marginTop = (SHEET_H - rows * CARD_PRINT_H) / 2;
return (
<div
ref={sheetRef}
style={{
width: SHEET_W,
height: SHEET_H,
backgroundColor: '#ffffff',
position: 'relative',
fontFamily: 'Georgia, serif',
}}
>
{slots.map((design, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = marginX + col * CARD_PRINT_W;
const y = marginTop + row * CARD_PRINT_H;
return (
<div
key={design ? design.id : `empty-${i}`}
style={{
position: 'absolute',
left: x,
top: y,
width: CARD_PRINT_W,
height: CARD_PRINT_H,
outline: '2px dashed rgba(0,0,0,0.25)',
overflow: 'hidden',
backgroundColor: design ? 'transparent' : '#fafafa',
}}
>
{design && (
<div style={{ transform: `scale(${CARD_SCALE})`, transformOrigin: 'top left' }}>
<CardFrame design={design} symbols={symbolsMap} />
</div>
)}
</div>
);
})}
</div>
);
}

275
pages/games/[id].js Normal file
View file

@ -0,0 +1,275 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import CardPreview from '../../components/designer/CardFrame';
import { Button, Input } from '../../components/ui';
import { useAuth } from '../../lib/use-auth';
export default function GameSpace() {
const router = useRouter();
const { id } = router.query;
const { user, loading: authLoading } = useAuth();
const [game, setGame] = useState(null);
const [designs, setDesigns] = useState([]);
const [symbolsMap, setSymbolsMap] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user || !id) return undefined;
const loadGame = async () => {
setLoading(true);
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-games/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const data = await response.json();
if (response.ok) {
setGame(data.game);
setDesigns(data.designs);
setName(data.game.name);
setDescription(data.game.description || '');
setIsPublic(Boolean(data.game.is_public));
} else {
setError(data.error || 'Game not found.');
}
} catch {
setError('Failed to load the game.');
} finally {
setLoading(false);
}
};
loadGame();
return undefined;
}, [user, id]);
// Load the owner's symbols so icon pips render in previews.
useEffect(() => {
if (!user) return undefined;
const loadSymbols = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-symbols', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
}
} catch {
// Non-fatal — text pips still work.
}
};
loadSymbols();
return undefined;
}, [user]);
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-games/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ name, description, is_public: isPublic }),
});
const data = await response.json();
if (response.ok) {
setGame((prev) => ({
...prev,
name: data.game.name,
description: data.game.description,
is_public: data.game.is_public,
}));
setEditing(false);
} else {
setError(data.error || 'Save failed.');
}
} catch {
setError('Save failed.');
} finally {
setSaving(false);
}
};
const toggleShare = async () => {
const next = !isPublic;
setIsPublic(next);
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-games/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ name: game.name, description: game.description, is_public: next }),
});
const data = await response.json();
if (response.ok) {
setGame((prev) => ({ ...prev, is_public: data.game.is_public }));
} else {
setIsPublic(!next);
setError(data.error || 'Could not update sharing.');
}
} catch {
setIsPublic(!next);
setError('Could not update sharing.');
}
};
const handleDelete = async () => {
if (!window.confirm(`Delete "${game?.name}"? Cards keep existing but leave the game.`)) return;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-games/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
router.push('/games');
} else {
setError('Delete failed.');
}
} catch {
setError('Delete failed.');
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
if (error && !game) {
return (
<Layout user={user}>
<div className="p-6 max-w-2xl mx-auto text-center py-20">
<h1 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{error}</h1>
<Button variant="primary" onClick={() => router.push('/games')}>Back to My Games</Button>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Header */}
<div className="pt-2 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
{editing ? (
<div className="flex-1 space-y-3">
<Input value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
maxLength={300}
/>
<div className="flex gap-2">
<Button variant="primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? 'Saving…' : 'Save'}
</Button>
<Button variant="secondary" onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
) : (
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{game.name}
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
{game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'}
{isPublic && (
<>
{' '}· <Link href={`/community/games/${game.id}`} style={{ color: 'var(--accent-ember)' }}>public view</Link>
</>
)}
</p>
</div>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={() => router.push('/games')}>My Games</Button>
<Button variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
<Button variant="secondary" onClick={toggleShare}>
{isPublic ? 'Shared ✓' : 'Share'}
</Button>
<Button variant="secondary" onClick={handleDelete}>Delete</Button>
<Button variant="primary" onClick={() => router.push(`/designer?game=${game.id}`)}>
+ New Card
</Button>
</div>
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
{/* Designs grid */}
{designs.length === 0 ? (
<div className="text-center py-16">
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No cards in this game yet
</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Design the first card for {game.name}.
</p>
<Button variant="primary" size="lg" onClick={() => router.push(`/designer?game=${game.id}`)}>
Open Card Designer
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
{designs.map((design) => (
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
<div
className="cursor-pointer"
onClick={() => router.push(`/designer?id=${design.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
>
<CardPreview design={design} maxWidth={280} symbols={symbolsMap} />
</div>
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{design.name}
</p>
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
</p>
</div>
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
Edit
</Button>
</div>
))}
</div>
)}
</div>
</Layout>
);
}

173
pages/games/index.js Normal file
View file

@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import { Button, Input } from '../../components/ui';
import { useAuth } from '../../lib/use-auth';
export default function MyGames() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return undefined;
const loadGames = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-games', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setGames(data.games);
} else {
setError('Failed to load your games.');
}
} catch {
setError('Failed to load your games.');
} finally {
setLoading(false);
}
};
loadGames();
return undefined;
}, [user]);
const handleCreate = async (e) => {
e.preventDefault();
if (!name.trim()) return;
setCreating(true);
setError(null);
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-games', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ name, description }),
});
const data = await response.json();
if (response.ok) {
setGames((prev) =>
[...prev, data.game].sort((a, b) => a.name.localeCompare(b.name))
);
setName('');
setDescription('');
} else {
setError(data.error || 'Could not create the game.');
}
} catch {
setError('Could not create the game.');
} finally {
setCreating(false);
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
<div className="pt-2">
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
My Games
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
Custom game systems you&apos;ve created private to you.
</p>
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
{/* Create form */}
<form onSubmit={handleCreate} className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
New Game
</h2>
<div className="flex flex-col sm:flex-row gap-3">
<Input
className="flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Game name (e.g. Aetherfall)"
maxLength={100}
required
/>
<Input
className="flex-1"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Short description (optional)"
maxLength={300}
/>
<Button variant="primary" type="submit" disabled={creating || !name.trim()}>
{creating ? 'Creating…' : 'Create Game'}
</Button>
</div>
</form>
{/* Games grid */}
{games.length === 0 ? (
<div className="text-center py-16">
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No custom games yet
</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Create your first game above, then target designs toward it in the{' '}
<Link href="/designer" style={{ color: 'var(--accent-ember)' }}>Card Designer</Link>.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{games.map((game) => (
<div
key={game.id}
className="glass-panel rounded-2xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg"
onClick={() => router.push(`/games/${game.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && router.push(`/games/${game.id}`)}
>
<h3 className="text-lg font-bold mb-1 truncate" style={{ color: 'var(--text-primary)' }}>
{game.name}
</h3>
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{game.description || 'No description'}
</p>
<span className="text-xs font-semibold" style={{ color: 'var(--accent-ember)' }}>
{game.card_count} card{game.card_count === 1 ? '' : 's'}
</span>
</div>
))}
</div>
)}
</div>
</Layout>
);
}

169
pages/my-designs.js Normal file
View file

@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CardPreview from '../components/designer/CardFrame';
import { gameLabel } from '../lib/designer-games.js';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
export default function MyDesigns() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [designs, setDesigns] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return undefined;
const loadDesigns = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-cards', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setDesigns(data.designs);
} else {
setError('Failed to load your designs.');
}
} catch {
setError('Failed to load your designs.');
} finally {
setLoading(false);
}
};
loadDesigns();
return undefined;
}, [user]);
const handleDelete = async (id) => {
if (!window.confirm('Delete this design? The card will remain in your collection.')) return;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-cards/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
setDesigns((prev) => prev.filter((d) => d.id !== id));
} else {
setError('Delete failed.');
}
} catch {
setError('Delete failed.');
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Header */}
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
My Designs
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
{designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection ·{' '}
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>
</p>
</div>
<Button variant="primary" onClick={() => router.push('/designer')}>
+ New Design
</Button>
{designs.length > 0 && (
<Button variant="secondary" onClick={() => router.push('/designer/print')}>
Print Sheet
</Button>
)}
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
{/* Designs grid */}
{!loading && designs.length === 0 && !error && (
<div className="text-center py-20">
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No designs yet
</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first custom card in the designer.
</p>
<Button variant="primary" size="lg" onClick={() => router.push('/designer')}>
Open Card Designer
</Button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
{designs.map((design) => (
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
<div
className="cursor-pointer"
onClick={() => router.push(`/designer?id=${design.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
>
<CardPreview design={design} maxWidth={280} />
</div>
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{design.name}
</p>
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
</p>
<p className="text-xs truncate" style={{ color: 'var(--accent-ember)' }}>
{design.custom_game_name
? design.custom_game_name
: design.game_target && design.game_target !== 'custom'
? gameLabel(design.game_target)
: 'Standalone'}
</p>
</div>
<div className="flex gap-2 mt-auto">
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
Edit
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDelete(design.id)}>
Delete
</Button>
</div>
</div>
))}
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
vi.mock('../../lib/permission-middleware', () => ({
getUserFromRequest: vi.fn(),
}));
import { sql } from '../../lib/sql.js';
import { getUserFromRequest } from '../../lib/permission-middleware';
import handler from '../../pages/api/custom-cards/index.js';
import itemHandler from '../../pages/api/custom-cards/[id].js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
};
return res;
}
describe('GET /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('requires authentication', async () => {
getUserFromRequest.mockResolvedValue(null);
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(401);
expect(sql).not.toHaveBeenCalled();
});
it('returns the user designs with resolved custom frames', async () => {
const design = { id: 5, name: 'Emberwing', card_id: 77 };
sql.mockResolvedValueOnce({ rows: [design] });
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(200);
expect(res.body.designs).toEqual([{ ...design, custom_frame: null }]);
});
});
describe('POST /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('rejects designs without a name', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: ' ', rarity: 'rare' } },
res
);
expect(res.statusCode).toBe(400);
expect(sql).not.toHaveBeenCalled();
});
it('creates the design, mirrors a catalog card, and links ownership', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: null, name: 'Emberwing Phoenix' }],
}) // INSERT custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // INSERT cards twin
.mockResolvedValueOnce({ rows: [] }) // INSERT user_cards
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'Emberwing Phoenix' }],
}); // link card_id back onto the design
const res = createRes();
await handler(
{
method: 'POST',
body: {
name: 'Emberwing Phoenix',
mana_cost: '2RR',
rarity: 'rare',
frameId: 'classic',
description: 'A phoenix reborn.',
actions: 'Flying, haste',
},
},
res
);
expect(res.statusCode).toBe(201);
expect(res.body.design.card_id).toBe(77);
// 4 statements: insert design, insert catalog twin, own row, link back
expect(sql).toHaveBeenCalledTimes(4);
});
it('normalizes camelCase and full-art fields on create', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 11, card_id: null, name: 'Void Mirror' }],
})
.mockResolvedValueOnce({ rows: [{ id: 78 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({
rows: [{ id: 11, card_id: 78, name: 'Void Mirror' }],
});
const res = createRes();
await handler(
{
method: 'POST',
body: {
name: 'Void Mirror',
artMode: 'fullart',
flavorQuote: 'What looks back is never the same twice.',
},
},
res
);
expect(res.statusCode).toBe(201);
const insertSql = sql.mock.calls[0][0].join('');
// art_mode coerced to the allowed set; flavor_quote mapped through
expect(insertSql).toContain('art_mode');
expect(insertSql).toContain('flavor_quote');
expect(sql.mock.calls[0]).toContain('fullart');
});
it('targets an existing game system without an extra lookup', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 12, card_id: null, name: 'Bolt Clone' }],
})
.mockResolvedValueOnce({ rows: [{ id: 80 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({
rows: [{ id: 12, card_id: 80, name: 'Bolt Clone' }],
});
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'Bolt Clone', gameTarget: 'MTG' } },
res
);
expect(res.statusCode).toBe(201);
// No custom_games lookup needed for a known system code
expect(sql).toHaveBeenCalledTimes(4);
const twinInsert = sql.mock.calls[1][0].join('');
expect(twinInsert).toContain('INSERT INTO cards');
expect(sql.mock.calls[1]).toContain('MTG');
});
it('resolves custom game names for the catalog twin', async () => {
sql
.mockResolvedValueOnce({ rows: [{ name: 'Aetherfall' }] }) // game lookup
.mockResolvedValueOnce({
rows: [{ id: 13, card_id: null, name: 'Sky Rune' }],
})
.mockResolvedValueOnce({ rows: [{ id: 81 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({
rows: [{ id: 13, card_id: 81, name: 'Sky Rune' }],
});
const res = createRes();
await handler(
{
method: 'POST',
body: { name: 'Sky Rune', gameTarget: 'custom', customGameId: 9 },
},
res
);
expect(res.statusCode).toBe(201);
expect(sql).toHaveBeenCalledTimes(5);
// [0] game lookup, [1] design insert, [2] catalog twin insert
expect(sql.mock.calls[2]).toContain('Aetherfall');
});
});
describe('/api/custom-cards/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('returns 404 when the design belongs to someone else', async () => {
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '12' } }, res);
expect(res.statusCode).toBe(404);
});
it('rejects invalid ids', async () => {
const res = createRes();
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
expect(res.statusCode).toBe(400);
});
it('updates the design and keeps the catalog twin in sync', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77, name: 'Old Name' }],
}) // ownership lookup hits
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'New Name' }],
}) // UPDATE custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // UPDATE cards twin
.mockResolvedValueOnce({ rows: [] }); // ensureOwnedRow (no-op if exists)
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '10' }, body: { name: 'New Name' } },
res
);
expect(res.statusCode).toBe(200);
expect(res.body.design.name).toBe('New Name');
// 4 statements: ownership, update design, update twin, own row
expect(sql).toHaveBeenCalledTimes(4);
});
it('deletes only the design row', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77 }],
}) // ownership lookup
.mockResolvedValueOnce({ rows: [] }); // DELETE custom_cards
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '10' } }, res);
expect(res.statusCode).toBe(200);
expect(sql).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
vi.mock('../../lib/permission-middleware', () => ({
getUserFromRequest: vi.fn(),
}));
import { sql } from '../../lib/sql.js';
import { getUserFromRequest } from '../../lib/permission-middleware';
import handler from '../../pages/api/custom-frames/index.js';
import itemHandler from '../../pages/api/custom-frames/[id].js';
import { validatePalette, PALETTE_SLOTS, validateLayout } from '../../lib/frame-palette.js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
};
return res;
}
const GOOD_PALETTE = Object.fromEntries(
PALETTE_SLOTS.map(({ key }) => [key, '#123456'])
);
const DEFAULT_LAYOUT = {
art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 },
text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 },
};
describe('validatePalette', () => {
it('accepts and normalizes a complete palette', () => {
const { palette, error } = validatePalette(
Object.fromEntries(PALETTE_SLOTS.map(({ key }) => [key, '#ABCDEF']))
);
expect(error).toBeUndefined();
expect(palette.outer).toBe('#abcdef');
});
it('rejects missing slots and bad colors', () => {
expect(validatePalette({}).error).toBeTruthy();
const missing = { ...GOOD_PALETTE };
delete missing.accent;
expect(validatePalette(missing).error).toBeTruthy();
expect(validatePalette({ ...GOOD_PALETTE, border: 'red' }).error).toBeTruthy();
});
});
describe('validateLayout', () => {
it('accepts and normalizes a valid layout', () => {
const { layout, error } = validateLayout({
art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 },
text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 },
});
expect(error).toBeUndefined();
expect(layout.art.x).toBeCloseTo(0.038);
});
it('rejects missing zones', () => {
expect(validateLayout({ art: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy();
});
it('rejects zones extending past the card', () => {
expect(validateLayout({ art: { x: 0.9, y: 0, w: 0.2, h: 0.5 }, text: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy();
});
});
describe('/api/custom-frames', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockReturnValue({ rows: [] });
sql.json = vi.fn((v) => v);
});
it('requires authentication', async () => {
getUserFromRequest.mockResolvedValue(null);
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(401);
expect(sql).not.toHaveBeenCalled();
});
it('rejects invalid palettes on create', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'My Frame', palette: { outer: 'nope' } } },
res
);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('Invalid or missing color');
});
it('rejects invalid layouts on create', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'My Frame', palette: GOOD_PALETTE, layout: { art: { x: 1, y: 0, w: 1, h: 1 } } } },
res
);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('extends past the card');
});
it('rejects duplicate frame names', async () => {
sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } },
res
);
expect(res.statusCode).toBe(409);
});
it('creates a frame with the normalized palette and default layout', async () => {
sql
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }],
});
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } },
res
);
expect(res.statusCode).toBe(201);
expect(res.body.frame.name).toBe('Molten');
expect(res.body.frame.layout).toBeDefined();
});
});
describe('/api/custom-frames/[id]', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
sql.json.mockImplementation((v) => v);
});
it('returns 404 when no frame exists', async () => {
sql.mockResolvedValueOnce({ rows: [] });
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '999' } }, res);
expect(res.statusCode).toBe(404);
});
it('updates name, palette, and layout', async () => {
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten' }] }); // found frame
sql.mockResolvedValueOnce({ rows: [] }); // clash check
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten II' }] }); // UPDATE RETURNING
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.02, y: 0.05, w: 0.96, h: 0.30 }, text: { x: 0.02, y: 0.50, w: 0.96, h: 0.35 } } } },
res
);
expect(res.statusCode).toBe(200);
expect(res.body.frame.name).toBe('Molten II');
});
it('deletes the frame', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
.mockResolvedValueOnce({ rows: [] }); // DELETE
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '4' } }, res);
expect(res.statusCode).toBe(200);
expect(sql).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
vi.mock('../../lib/permission-middleware', () => ({
getUserFromRequest: vi.fn(),
}));
import { sql } from '../../lib/sql.js';
import { getUserFromRequest } from '../../lib/permission-middleware';
import handler from '../../pages/api/custom-games/index.js';
import itemHandler from '../../pages/api/custom-games/[id].js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
};
return res;
}
describe('/api/custom-games', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('requires authentication', async () => {
getUserFromRequest.mockResolvedValue(null);
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(401);
expect(sql).not.toHaveBeenCalled();
});
it('rejects games without a name', async () => {
const res = createRes();
await handler({ method: 'POST', body: { name: ' ' } }, res);
expect(res.statusCode).toBe(400);
expect(sql).not.toHaveBeenCalled();
});
it('rejects duplicate names for the same user', async () => {
sql.mockResolvedValueOnce({ rows: [{ id: 9 }] }); // duplicate lookup hits
const res = createRes();
await handler({ method: 'POST', body: { name: 'Aetherfall' } }, res);
expect(res.statusCode).toBe(409);
});
it('creates a game scoped to the user', async () => {
sql
.mockResolvedValueOnce({ rows: [] }) // duplicate lookup misses
.mockResolvedValueOnce({
rows: [{ id: 9, name: 'Aetherfall', description: null }],
});
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'Aetherfall', description: 'Skyborn TCG' } },
res
);
expect(res.statusCode).toBe(201);
expect(res.body.game.name).toBe('Aetherfall');
expect(res.body.game.card_count).toBe(0);
});
});
describe('/api/custom-games/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('returns 404 for another user\'s game', async () => {
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
expect(res.statusCode).toBe(404);
});
it('returns the game with its designs', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Aetherfall' }] }) // ownership
.mockResolvedValueOnce({ rows: [{ id: 3, name: 'Stormsage' }] }); // designs
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
expect(res.statusCode).toBe(200);
expect(res.body.game.name).toBe('Aetherfall');
expect(res.body.designs).toEqual([{ id: 3, name: 'Stormsage', custom_frame: null }]);
});
it('rejects renames that clash with another game', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Old Name' }] }) // ownership
.mockResolvedValueOnce({ rows: [{ id: 12 }] }); // clash lookup hits
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '9' }, body: { name: 'Taken' } },
res
);
expect(res.statusCode).toBe(409);
});
it('deletes the game row', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Aetherfall' }] }) // ownership
.mockResolvedValueOnce({ rows: [] }); // DELETE
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '9' } }, res);
expect(res.statusCode).toBe(200);
expect(sql).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/object-storage.js', () => ({
put: vi.fn(),
del: vi.fn(),
}));
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
vi.mock('../../lib/permission-middleware', () => ({
getUserFromRequest: vi.fn(),
}));
vi.mock('../../lib/rate-limit.js', () => ({
checkUploadRateLimit: vi.fn(),
}));
import { put, del } from '../../lib/object-storage.js';
import { sql } from '../../lib/sql.js';
import { getUserFromRequest } from '../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../lib/rate-limit.js';
import handler from '../../pages/api/custom-symbols/index.js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
setHeader() {
return res;
},
};
return res;
}
/** Build a fake multipart request whose body carries the given parts. */
function multipartRequest(fields) {
const boundary = 'testboundary';
const body =
fields.map((f) => `--${boundary}\r\n${f}\r\n`).join('') + `--${boundary}--\r\n`;
return {
method: 'POST',
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
on(event, cb) {
if (event === 'data') cb(Buffer.from(body));
if (event === 'end') cb();
},
};
}
function filePart(name, filename, type) {
return `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\nBINARYDATA`;
}
function textPart(name, value) {
return `Content-Disposition: form-data; name="${name}"\r\n\r\n${value}`;
}
describe('/api/custom-symbols', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
checkUploadRateLimit.mockResolvedValue({ allowed: true, reset: Date.now() + 60000 });
sql.mockResolvedValue({ rows: [] });
put.mockResolvedValue({ url: 'https://cdn.example.com/symbols/x.png' });
});
it('requires authentication', async () => {
getUserFromRequest.mockResolvedValue(null);
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(401);
expect(sql).not.toHaveBeenCalled();
});
it('rejects invalid symbol codes', async () => {
const req = multipartRequest([
filePart('image', 'f.png', 'image/png'),
textPart('code', 'bad code!'),
]);
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('1-10 letters');
});
it('rejects unsupported file types', async () => {
const req = multipartRequest([
filePart('image', 'f.gif', 'image/gif'),
textPart('code', 'F'),
]);
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('PNG, WebP, or SVG');
});
it('uploads a symbol and returns its URL', async () => {
sql
.mockResolvedValueOnce({ rows: [] }) // existing lookup misses
.mockResolvedValueOnce({
rows: [{ id: 7, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
});
const req = multipartRequest([
filePart('image', 'fire.png', 'image/png'),
textPart('code', 'F'),
]);
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(201);
expect(res.body.symbol.code).toBe('F');
expect(put).toHaveBeenCalledTimes(1);
expect(put.mock.calls[0][0]).toContain('symbols/1-f-');
});
it('replaces an existing symbol with the same code', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 6, image_url: 'https://cdn.example.com/symbols/old.png' }],
}) // existing lookup hits
.mockResolvedValueOnce({
rows: [{ id: 6, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
});
const req = multipartRequest([
filePart('image', 'fire.png', 'image/png'),
textPart('code', 'F'),
]);
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(del).toHaveBeenCalledWith('https://cdn.example.com/symbols/old.png');
});
});

View file

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
import { sql } from '../../lib/sql.js';
import listHandler from '../../pages/api/public/games/index.js';
import itemHandler from '../../pages/api/public/games/[id].js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
};
return res;
}
describe('/api/public/games', () => {
beforeEach(() => {
vi.clearAllMocks();
sql.mockResolvedValue({ rows: [] });
});
it('rejects non-GET methods', async () => {
const res = createRes();
await listHandler({ method: 'POST' }, res);
expect(res.statusCode).toBe(405);
expect(sql).not.toHaveBeenCalled();
});
it('lists public games with author and card counts', async () => {
sql.mockResolvedValueOnce({
rows: [{ id: 9, name: 'Aetherfall', author: 'rstillw', card_count: 4 }],
});
const res = createRes();
await listHandler({ method: 'GET' }, res);
expect(res.statusCode).toBe(200);
expect(res.body.games).toHaveLength(1);
const listed = sql.mock.calls[0][0].join('');
expect(listed).toContain('is_public = true');
});
});
describe('/api/public/games/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
sql.mockResolvedValue({ rows: [] });
});
it('rejects invalid ids', async () => {
const res = createRes();
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
expect(res.statusCode).toBe(400);
});
it('404s private or missing games', async () => {
sql.mockResolvedValueOnce({ rows: [] }); // public filter misses
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
expect(res.statusCode).toBe(404);
});
it('returns the game and its designs with resolved frames', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 9, name: 'Aetherfall', author: 'rstillw' }],
})
.mockResolvedValueOnce({
rows: [
{
id: 3,
name: 'Stormsage',
frame_pk: 2,
frame_name: 'Molten',
frame_palette: { outer: '#111111' },
frame_texture: null,
},
],
});
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
expect(res.statusCode).toBe(200);
expect(res.body.game.name).toBe('Aetherfall');
expect(res.body.designs[0].custom_frame.name).toBe('Molten');
expect(res.body.designs[0].frame_pk).toBeUndefined();
});
});

View file

@ -1 +0,0 @@
{}