Compare commits
No commits in common. "106bd9d5922790b5a297ff2a377db6d6ee07812c" and "ca3b8a78c244775c10db58ccfb71cc0edb66a69d" have entirely different histories.
106bd9d592
...
ca3b8a78c2
50 changed files with 62 additions and 6425 deletions
|
|
@ -4,7 +4,7 @@ classification: infra
|
||||||
success_metric: |
|
success_metric: |
|
||||||
Deck Hearth runs on CT 102 Postgres + MinIO + Redis, app on Dokploy (CT 112);
|
Deck Hearth runs on CT 102 Postgres + MinIO + Redis, app on Dokploy (CT 112);
|
||||||
Neon and Vercel hosting decommissioned; Vercel AI Gateway retained.
|
Neon and Vercel hosting decommissioned; Vercel AI Gateway retained.
|
||||||
status: shipped
|
status: in_progress
|
||||||
created: 2026-08-15
|
created: 2026-08-15
|
||||||
depends_on: []
|
depends_on: []
|
||||||
skip:
|
skip:
|
||||||
|
|
@ -72,26 +72,6 @@ NEON_DATABASE_URL=… # one-time source only
|
||||||
|
|
||||||
| Risk | Mitigation |
|
| Risk | Mitigation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| LAN-only DB | ✅ RESOLVED — app now on Dokploy CT 112, DB on CT 102 same LAN |
|
| LAN-only DB | Deploy app on Coolify, not Vercel prod |
|
||||||
| `deckhearth_ci` password drift | Match `HOMELAB_CI_POSTGRES_PASSWORD` GitHub secret |
|
| `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 |
|
| Init SQL on live CT 102 | Manual `docker exec psql` apply, not initdb.d replay |
|
||||||
|
|
||||||
## As-shipped
|
|
||||||
|
|
||||||
**Phase 1–5 (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.
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ skip:
|
||||||
- role-a11y-auditor
|
- role-a11y-auditor
|
||||||
- role-ux-reviewer
|
- role-ux-reviewer
|
||||||
- role-ia-architect
|
- role-ia-architect
|
||||||
status: shipped
|
status: open
|
||||||
created: 2026-06-14
|
created: 2026-06-14
|
||||||
parent: migration-tool
|
parent: migration-tool
|
||||||
addresses: migration-tool § R1 (prod schema drift from setup-neon-db.js DDL)
|
addresses: migration-tool § R1 (prod schema drift from setup-neon-db.js DDL)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ success_metric: |
|
||||||
Scans / Scan Queue / Duplicates — without regressing the mobile
|
Scans / Scan Queue / Duplicates — without regressing the mobile
|
||||||
immersive checkout.
|
immersive checkout.
|
||||||
skip: []
|
skip: []
|
||||||
status: shipped
|
status: open
|
||||||
created: 2026-08-15
|
created: 2026-08-15
|
||||||
depends_on:
|
depends_on:
|
||||||
- scanner-mobile-checkout
|
- scanner-mobile-checkout
|
||||||
|
|
@ -887,16 +887,3 @@ slice_dependencies:
|
||||||
5. **No new packages** — dep-set check N/A; all primitives exist (`Modal`, `GlassSurface`, `Button`).
|
5. **No new packages** — dep-set check N/A; all primitives exist (`Modal`, `GlassSurface`, `Button`).
|
||||||
6. **Gallery batch path verified:** `identifyFromGalleryFile` exists in
|
6. **Gallery batch path verified:** `identifyFromGalleryFile` exists in
|
||||||
`use-scanner-identification.js` (line 326) — Brief 5 wraps it sequentially.
|
`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).
|
|
||||||
|
|
|
||||||
65
AGENTS.md
65
AGENTS.md
|
|
@ -3,23 +3,15 @@
|
||||||
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
|
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
|
> Branding note: this product is **Deck Hearth** as of 2026-05-24
|
||||||
> (`pick-a-name` convoy, squash commit `9abbab6`, PR #21). The GitHub
|
> (`pick-a-name` convoy, squash commit `9abbab6`, PR #21). The repo
|
||||||
> repo is now `stwl-labs/deckhearth` (renamed from `stwl-labs/tcg-vault`;
|
> and Vercel project are still named `tcg-vault` — that rename is
|
||||||
> local checkout folders named `tcg-vault` are fine). Admin email is
|
> tracked in the queued `rename-repo-and-vercel-project` convoy
|
||||||
|
> (auto-redirects make it low-urgency). Admin email is
|
||||||
> `admin@deckhearth.com`; the prior `admin@tcgvault.com` literal is
|
> `admin@deckhearth.com`; the prior `admin@tcgvault.com` literal is
|
||||||
> deliberately preserved in `test/lib/permission-middleware.test.js`
|
> deliberately preserved in `test/lib/permission-middleware.test.js`
|
||||||
> as a historical regression-lock per Risk 4 of the pick-a-name
|
> as a historical regression-lock per Risk 4 of the pick-a-name
|
||||||
> convoy.
|
> 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 6–8). See § 5–§ 7 and
|
|
||||||
> `docs/DOKPLOY_DEPLOY.md` / `docs/HOMELAB_DATABASE.md`.
|
|
||||||
|
|
||||||
## Product vocabulary
|
## 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.
|
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.
|
||||||
|
|
@ -62,10 +54,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.
|
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)
|
- **Framework:** Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9)
|
||||||
- **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.
|
- **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 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).
|
- **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).
|
||||||
- **UI:** Tailwind CSS + custom CSS variables for theming (light/dark via `lib/theme-context.js`)
|
- **UI:** Tailwind CSS + custom CSS variables for theming (light/dark via `lib/theme-context.js`)
|
||||||
- **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 6–8) — see `docs/DOKPLOY_DEPLOY.md`.
|
- **Hosting:** Vercel (`vercel.json`, `.vercel/` present)
|
||||||
|
|
||||||
## 2. Architecture quick reference
|
## 2. Architecture quick reference
|
||||||
|
|
||||||
|
|
@ -74,10 +66,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` |
|
| 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`** |
|
| 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 |
|
| 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` (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`). |
|
| 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. |
|
||||||
| Migration scripts | `scripts/*.js` | 27+ one-off "add column" / "seed" scripts. No formal migration tool |
|
| 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 |
|
| Card-import jobs | `pages/api/cards/import-*.js`, `scripts/import-*.js` | Scryfall / Lorcana / Pokémon TCG APIs |
|
||||||
| 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 |
|
| Database schema | `scripts/setup-neon-db.js` | Bootstrap SQL DDL — the source of truth until a real migration tool lands |
|
||||||
| Schema map | `docs/SCHEMA_MAP.md` | Hand-curated; regenerate after schema changes |
|
| 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.
|
Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 5602 edges). Ask: *"what calls `getUserFromRequest`?"* before refactoring auth.
|
||||||
|
|
@ -91,7 +83,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.
|
- **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.
|
- **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`.
|
- **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 '../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.
|
- **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.
|
||||||
- **Activity logging:** `logCollectionActivity(collectionId, userId, action, details)` — call it from any handler that mutates a collection.
|
- **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.
|
- **File names:** `kebab-case.js` for libs/scripts; `PascalCase.js` for React components.
|
||||||
- **Imports:** No path aliases configured; use relative imports.
|
- **Imports:** No path aliases configured; use relative imports.
|
||||||
|
|
@ -130,9 +122,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.
|
- **#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".
|
- **#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`.
|
- **#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 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.
|
- **#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.
|
||||||
|
|
||||||
**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:
|
**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:
|
||||||
|
|
||||||
| Helper | Class | Limit/window | Key | Redis prefix | Routes |
|
| Helper | Class | Limit/window | Key | Redis prefix | Routes |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
|
@ -141,31 +133,30 @@ 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` |
|
| `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` |
|
| `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` |
|
| `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.
|
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 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).
|
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).
|
||||||
|
|
||||||
**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.
|
**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 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.
|
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.
|
||||||
|
|
||||||
## 5. Running locally
|
## 5. Running locally
|
||||||
|
|
||||||
- **Runtime:** Node 20+ (`"type": "module"` — ESM everywhere).
|
- **Runtime:** Node 20 (Vercel default).
|
||||||
- **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`.
|
- **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.
|
||||||
- **Dev server:** `npm run dev` → http://localhost:3000.
|
- **Dev server:** `npm run dev` → http://localhost:3000.
|
||||||
|
|
||||||
## 6. Testing
|
## 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, 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).
|
- **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:** 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.
|
- **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.
|
||||||
- **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:
|
- **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.
|
- `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.
|
- `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`. 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`).
|
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`).
|
||||||
- **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).
|
- **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:
|
- **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:
|
||||||
|
|
||||||
|
|
@ -206,21 +197,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
|
||||||
|
|
||||||
## 7. Deployment
|
## 7. Deployment
|
||||||
|
|
||||||
> **Status (2026-08): production is the Dokploy homelab 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).
|
||||||
> 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:
|
- **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`).
|
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.
|
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.
|
||||||
|
|
@ -229,7 +206,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`.
|
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.
|
- **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 Dokploy builds `main` on CT 112 (Vercel preview builds continue only until phase 8 decommission) — 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 Vercel still builds previews — 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.
|
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`:
|
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`:
|
||||||
|
|
||||||
|
|
|
||||||
82
README.md
82
README.md
|
|
@ -1,10 +1,6 @@
|
||||||
# Deck Hearth
|
# Deck Hearth
|
||||||
|
|
||||||
A modern trading card game collection manager built with Next.js and PostgreSQL.
|
A modern trading card game collection manager built with Next.js and Neon Database.
|
||||||
|
|
||||||
> 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
|
## 🚀 Features
|
||||||
|
|
||||||
|
|
@ -19,20 +15,19 @@ A modern trading card game collection manager built with Next.js and PostgreSQL.
|
||||||
|
|
||||||
- **Frontend**: Next.js 16 (Pages router), React 18, JavaScript (TypeScript is a devDep only — see `AGENTS.md` Gotcha #9)
|
- **Frontend**: Next.js 16 (Pages router), React 18, JavaScript (TypeScript is a devDep only — see `AGENTS.md` Gotcha #9)
|
||||||
- **Backend**: Next.js API Routes
|
- **Backend**: Next.js API Routes
|
||||||
- **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`
|
- **Database**: Neon PostgreSQL (serverless)
|
||||||
- **Authentication**: JWT with bcrypt (24-hour expiry; `lib/auth-secret.js` is the single source of truth for `JWT_SECRET`)
|
- **Authentication**: JWT with bcrypt (24-hour expiry; `lib/auth-secret.js` is the single source of truth for `JWT_SECRET`)
|
||||||
- **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
|
- **Rate limiting**: `@upstash/ratelimit` on `/api/auth/login` + `/api/auth/register` (5 attempts / 15 min per IP)
|
||||||
- **Testing**: Vitest (unit) + Playwright (`smoke` and `visual` projects)
|
- **Testing**: Vitest (unit); Playwright queued
|
||||||
- **Object storage**: MinIO on CT 102 (scan captures; S3-compatible via `@aws-sdk/client-s3`)
|
- **Styling**: Tailwind CSS
|
||||||
- **Styling**: Tailwind CSS + Liquid Glass design tokens (`docs/DESIGN_TOKENS.md`)
|
- **Deployment**: Vercel
|
||||||
- **Deployment**: Dokploy on CT 112, public URL `https://deckhearth.stillwell.cloud` via Traefik — see `docs/DOKPLOY_DEPLOY.md`
|
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/stwl-labs/deckhearth.git
|
git clone <repository-url>
|
||||||
cd deckhearth
|
cd tcg-vault
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
|
|
@ -40,32 +35,27 @@ A modern trading card game collection manager built with Next.js and PostgreSQL.
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Create `.env.local`** (there is no committed template — use the shape below)
|
3. **Set up environment variables**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `.env.local` with your Neon database URL and a real JWT secret:
|
||||||
```env
|
```env
|
||||||
# Required — homelab Postgres (CT 102) or any Postgres 17 instance
|
POSTGRES_URL="postgresql://your-username:your-password@your-host/your-database"
|
||||||
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>"
|
JWT_SECRET="<generate with: openssl rand -hex 32>"
|
||||||
# Required for `npm run setup-db` — used once to hash the initial admin password.
|
# 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>"
|
ADMIN_INITIAL_PASSWORD="<generate with: openssl rand -base64 24>"
|
||||||
# Rate limiting (homelab Redis, CT 102 — DB index 5 in prod). Optional in dev —
|
# Optional — exercise the rate limiter locally. Without them, `lib/rate-limit.js`
|
||||||
# without it, `lib/rate-limit.js` warn-and-no-ops locally; production fails closed.
|
# warn-and-no-ops in dev. In production these are auto-provisioned by the
|
||||||
REDIS_URL="redis://:password@192.168.68.102:6379/5"
|
# Vercel Upstash Marketplace integration.
|
||||||
# Scan-capture uploads (MinIO on CT 102, S3-compatible — see lib/object-storage.js)
|
KV_REST_API_URL="https://<your-upstash-host>.upstash.io"
|
||||||
S3_ENDPOINT="http://192.168.68.102:9000"
|
KV_REST_API_TOKEN="<your-upstash-rest-token>"
|
||||||
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.
|
`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.
|
`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**
|
4. **Set up the database**
|
||||||
```bash
|
```bash
|
||||||
npm run setup-db
|
npm run setup-db
|
||||||
|
|
@ -137,35 +127,27 @@ The application uses the following tables:
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
Deck Hearth deploys to **Dokploy on CT 112** in the axiom homelab, fronted by
|
This app is configured for deployment on Vercel:
|
||||||
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).
|
|
||||||
|
|
||||||
> **Transition note:** the Vercel + Neon era is being decommissioned
|
1. **Connect your repository** to Vercel
|
||||||
> (`migrate-neon-to-homelab` convoy, phases 6–8). `vercel.json` and `.vercel/`
|
2. **Set environment variables** in Vercel dashboard
|
||||||
> remain in the tree until that decommission lands; CI already gates against
|
3. **Deploy automatically** on push to main branch
|
||||||
> the homelab deployment.
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
deckhearth/ (formerly tcg-vault)
|
tcg-vault/
|
||||||
├── pages/ # Next.js pages and API routes
|
├── pages/ # Next.js pages and API routes
|
||||||
│ ├── api/ # API endpoints
|
│ ├── api/ # API endpoints
|
||||||
│ │ ├── auth/ # Authentication routes
|
│ │ ├── auth/ # Authentication routes
|
||||||
│ │ └── admin/ # Admin routes
|
│ │ └── admin/ # Admin routes
|
||||||
│ ├── _app.js # App wrapper
|
│ ├── _app.js # App wrapper
|
||||||
│ └── index.js # Home page
|
│ └── index.js # Home page
|
||||||
├── components/ # React components (+ scanner/ subfamily)
|
├── lib/ # Utility libraries
|
||||||
├── lib/ # Utility libraries
|
│ └── database.js # Database adapter
|
||||||
│ └── sql.js # Canonical Postgres client (tagged templates)
|
├── scripts/ # Database setup scripts
|
||||||
├── migrations/ # node-pg-migrate migrations (source of truth for DDL)
|
├── public/ # Static assets
|
||||||
├── scripts/ # Setup, import, and historical one-off jobs
|
└── .env.local # Environment variables
|
||||||
├── 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
|
## 🔐 First-time admin setup
|
||||||
|
|
@ -179,7 +161,7 @@ variable is unset or empty.
|
||||||
- **Local dev:** set `ADMIN_INITIAL_PASSWORD` in `.env.local` before running
|
- **Local dev:** set `ADMIN_INITIAL_PASSWORD` in `.env.local` before running
|
||||||
`npm run setup-db`. Use `openssl rand -base64 24` (or any other strong
|
`npm run setup-db`. Use `openssl rand -base64 24` (or any other strong
|
||||||
source) to generate the value.
|
source) to generate the value.
|
||||||
- **CI:** set `ADMIN_INITIAL_PASSWORD` as a repo secret if setup
|
- **CI / Vercel:** set `ADMIN_INITIAL_PASSWORD` as a project secret if setup
|
||||||
ever runs from CI. The env var is **only** read by the seed script; runtime
|
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.
|
auth uses the per-user password stored in the database.
|
||||||
- **Admin email:** the seed creates `admin@deckhearth.com`. Change the password
|
- **Admin email:** the seed creates `admin@deckhearth.com`. Change the password
|
||||||
|
|
|
||||||
|
|
@ -209,14 +209,6 @@ function NavigationContent({ user, router, onItemClick }) {
|
||||||
router.pathname === '/decks' ||
|
router.pathname === '/decks' ||
|
||||||
router.pathname.startsWith('/deck/'),
|
router.pathname.startsWith('/deck/'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Designer',
|
|
||||||
href: '/my-designs',
|
|
||||||
icon: 'designer',
|
|
||||||
active:
|
|
||||||
router.pathname === '/designer' ||
|
|
||||||
router.pathname === '/my-designs',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Scanner',
|
name: 'Scanner',
|
||||||
href: '/scanner',
|
href: '/scanner',
|
||||||
|
|
@ -253,7 +245,6 @@ function NavigationContent({ user, router, onItemClick }) {
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
|
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
|
||||||
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
|
{ 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' }
|
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -327,11 +318,6 @@ 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="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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</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;
|
return icons[iconName] || icons.grid;
|
||||||
|
|
|
||||||
|
|
@ -1,877 +0,0 @@
|
||||||
/* 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 & 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 }}>“</span>}
|
|
||||||
suffix={<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>”</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/**
|
|
||||||
* 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];
|
|
||||||
}
|
|
||||||
|
|
@ -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] `deckhearth` Postgres — `provision-deckhearth.sh`
|
||||||
- [x] MinIO bucket — `provision-deckhearth-minio.sh` (axiom-server)
|
- [x] MinIO bucket — `provision-deckhearth-minio.sh` (axiom-server)
|
||||||
- [ ] 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.)
|
- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (optional)
|
||||||
|
|
||||||
## 2. Dokploy application
|
## 2. Dokploy application
|
||||||
|
|
||||||
|
|
@ -76,10 +76,9 @@ Weekly: `0 6 * * 1` (Mondays 06:00).
|
||||||
|
|
||||||
## 6. Decommission Vercel
|
## 6. Decommission Vercel
|
||||||
|
|
||||||
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.
|
1. Point DNS / AdGuard rewrite for `deckhearth.stillwell.cloud` at Traefik (already `*.stillwell.cloud` → CT 100).
|
||||||
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.
|
2. Disable Vercel project auto-deploy or delete project after Coolify/Dokploy is stable.
|
||||||
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.
|
3. Remove unused env vars from Vercel dashboard (Neon, Blob, Upstash).
|
||||||
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
|
## 7. Local dev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
# 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 80–200ms. If this escalates, L1 runs next.
|
|
||||||
- **Layer 1 OCR:** Tesseract runs in-browser; expect 600–1200ms. If name extraction fails or confidence is low, escalates to L2.
|
|
||||||
- **Layer 2 Vision API:** Network call to `/api/scan/identify` (Gemini/OpenAI). Typically 2–8 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.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +1,5 @@
|
||||||
import { isValidCardQuad, warpCardCaptureFromVideo } from './scanner-card-warp.js';
|
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. */
|
/** Default margin (px) around tracked bounds when cropping a card capture. */
|
||||||
export const CAPTURE_MARGIN_PX = 20;
|
export const CAPTURE_MARGIN_PX = 20;
|
||||||
|
|
||||||
|
|
@ -285,23 +266,15 @@ export async function fetchIdentifyByImage(imageData, authHeaders, game) {
|
||||||
* Layer 0: visual kNN against precomputed catalog embeddings.
|
* Layer 0: visual kNN against precomputed catalog embeddings.
|
||||||
*/
|
*/
|
||||||
export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
|
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 l0 = await fetchIdentifyByImage(imageData, authHeaders, game);
|
||||||
const durationMs = Date.now() - startMs;
|
|
||||||
|
|
||||||
if (!l0.ok) {
|
if (!l0.ok) {
|
||||||
debugLog('⚠️', `Layer 0 failed → ${durationMs}ms`, { rateLimited: l0.rateLimited });
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l0.result.escalate) {
|
if (l0.result.escalate) {
|
||||||
debugLog('⬆️', `Layer 0 escalating → ${durationMs}ms`, { reason: l0.result.escalateReason || 'low confidence' });
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLog('✅', `Layer 0 resolved → ${durationMs}ms`, { card: l0.result.card?.name, matches: l0.result.matches?.length });
|
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
outcome: resolveIdentifyOutcome(l0.result),
|
outcome: resolveIdentifyOutcome(l0.result),
|
||||||
|
|
@ -309,30 +282,22 @@ export async function tryLayer0VisualIdentify(imageData, authHeaders, game) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVisionIdentify(imageData, authHeaders) {
|
export async function fetchVisionIdentify(imageData, authHeaders) {
|
||||||
const startMs = Date.now();
|
|
||||||
debugLog('🤖', 'Layer 2 (Vision API) call started');
|
|
||||||
|
|
||||||
const response = await fetch('/api/scan/identify', {
|
const response = await fetch('/api/scan/identify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
body: JSON.stringify({ imageData }),
|
body: JSON.stringify({ imageData }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
debugLog('🚫', `Vision API rate limited → ${durationMs}ms`);
|
|
||||||
return { ok: false, rateLimited: true };
|
return { ok: false, rateLimited: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errBody = await response.json().catch(() => ({}));
|
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}`);
|
throw new Error(errBody.error || `Scan identify failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
debugLog('✅', `Vision API success → ${durationMs}ms`, { card: result.card?.name, needsUserSelection: result.needsUserSelection });
|
|
||||||
return { ok: true, result };
|
return { ok: true, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,18 +306,10 @@ export async function fetchVisionIdentify(imageData, authHeaders) {
|
||||||
* Returns { handled: true, outcome } when L1 resolves without escalation.
|
* Returns { handled: true, outcome } when L1 resolves without escalation.
|
||||||
*/
|
*/
|
||||||
export async function tryLayer1TextIdentify(imageData, authHeaders) {
|
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 { recognizeCardFields } = await import('./ocr-worker.js');
|
||||||
const ocrStartMs = Date.now();
|
|
||||||
const ocr = await recognizeCardFields(imageData);
|
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) {
|
if (ocr.nameText.length < 3) {
|
||||||
debugLog('⚠️', `Layer 1 skipped (name too short: "${ocr.nameText}") → ${Date.now() - startMs}ms`);
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,14 +320,10 @@ export async function tryLayer1TextIdentify(imageData, authHeaders) {
|
||||||
authHeaders,
|
authHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
|
||||||
|
|
||||||
if (!l1.ok || l1.result.escalate) {
|
if (!l1.ok || l1.result.escalate) {
|
||||||
debugLog('⬆️', `Layer 1 escalating → ${durationMs}ms`, { escalate: l1.result?.escalate });
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLog('✅', `Layer 1 resolved → ${durationMs}ms`, { card: l1.result.card?.name, matches: l1.result.matches?.length });
|
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
outcome: resolveIdentifyOutcome(l1.result),
|
outcome: resolveIdentifyOutcome(l1.result),
|
||||||
|
|
@ -387,9 +340,6 @@ export async function identifyTrackedCardCapture({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
visionCooldownUntilMs = 0,
|
visionCooldownUntilMs = 0,
|
||||||
}) {
|
}) {
|
||||||
const pipelineStartMs = Date.now();
|
|
||||||
debugLog('🎯', `Card verification started (tracker-${cardTracker.id})`);
|
|
||||||
|
|
||||||
const imageData = captureCardRegionFromVideo(
|
const imageData = captureCardRegionFromVideo(
|
||||||
video,
|
video,
|
||||||
canvas,
|
canvas,
|
||||||
|
|
@ -401,38 +351,30 @@ export async function identifyTrackedCardCapture({
|
||||||
try {
|
try {
|
||||||
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
|
const l0 = await tryLayer0VisualIdentify(imageData, authHeaders);
|
||||||
if (l0.handled) {
|
if (l0.handled) {
|
||||||
debugLog('🎉', `Pipeline complete (L0) → ${Date.now() - pipelineStartMs}ms total`);
|
|
||||||
return { imageData, ...l0 };
|
return { imageData, ...l0 };
|
||||||
}
|
}
|
||||||
} catch (l0Error) {
|
} catch (l0Error) {
|
||||||
console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error);
|
console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error);
|
||||||
debugLog('⚠️', 'Layer 0 exception', { error: l0Error.message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
|
const l1 = await tryLayer1TextIdentify(imageData, authHeaders);
|
||||||
if (l1.handled) {
|
if (l1.handled) {
|
||||||
debugLog('🎉', `Pipeline complete (L1) → ${Date.now() - pipelineStartMs}ms total`);
|
|
||||||
return { imageData, ...l1 };
|
return { imageData, ...l1 };
|
||||||
}
|
}
|
||||||
} catch (l1Error) {
|
} catch (l1Error) {
|
||||||
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
|
console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error);
|
||||||
debugLog('⚠️', 'Layer 1 exception', { error: l1Error.message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < visionCooldownUntilMs) {
|
if (Date.now() < visionCooldownUntilMs) {
|
||||||
const cooldownSecondsRemaining = Math.ceil((visionCooldownUntilMs - Date.now()) / 1000);
|
|
||||||
debugLog('⏸️', `Vision cooldown active — retry in ${cooldownSecondsRemaining}s`);
|
|
||||||
return { imageData, retry: true };
|
return { imageData, retry: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const vision = await fetchVisionIdentify(imageData, authHeaders);
|
const vision = await fetchVisionIdentify(imageData, authHeaders);
|
||||||
if (vision.rateLimited) {
|
if (vision.rateLimited) {
|
||||||
debugLog('🚫', `Pipeline halted (rate limited) → ${Date.now() - pipelineStartMs}ms total`);
|
|
||||||
return { imageData, rateLimited: true };
|
return { imageData, rateLimited: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLog('🎉', `Pipeline complete (L2) → ${Date.now() - pipelineStartMs}ms total`);
|
|
||||||
return {
|
return {
|
||||||
imageData,
|
imageData,
|
||||||
handled: true,
|
handled: true,
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,6 @@ import {
|
||||||
fetchVisionIdentify,
|
fetchVisionIdentify,
|
||||||
} from './scanner-card-identify.js';
|
} 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.
|
* Card identification, disambiguation, and review-submission flow for the scanner.
|
||||||
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
|
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
|
||||||
|
|
@ -317,9 +298,6 @@ export function useScannerIdentification({
|
||||||
if (disambiguation) return;
|
if (disambiguation) return;
|
||||||
if (activeVerificationRef.current >= 1) return;
|
if (activeVerificationRef.current >= 1) return;
|
||||||
|
|
||||||
const verifyStartMs = Date.now();
|
|
||||||
debugLog('🎯', `Shutter pressed (tracker-${cardTracker.id}, attempt ${cardTracker.scanAttempts + 1})`);
|
|
||||||
|
|
||||||
activeVerificationRef.current += 1;
|
activeVerificationRef.current += 1;
|
||||||
cardTracker.status = 'verifying';
|
cardTracker.status = 'verifying';
|
||||||
setIsIdentifying(true);
|
setIsIdentifying(true);
|
||||||
|
|
@ -336,15 +314,12 @@ export function useScannerIdentification({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (identification.retry) {
|
if (identification.retry) {
|
||||||
debugLog('⏸️', `Verification skipped (cooldown active) → ${Date.now() - verifyStartMs}ms`);
|
|
||||||
cardTracker.status = 'detecting';
|
cardTracker.status = 'detecting';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identification.rateLimited) {
|
if (identification.rateLimited) {
|
||||||
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
|
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.');
|
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
cardTracker.negativeAt = Date.now();
|
cardTracker.negativeAt = Date.now();
|
||||||
|
|
@ -353,16 +328,13 @@ export function useScannerIdentification({
|
||||||
|
|
||||||
if (identification.handled && identification.outcome) {
|
if (identification.handled && identification.outcome) {
|
||||||
cardTracker.status = 'confirmed';
|
cardTracker.status = 'confirmed';
|
||||||
debugLog('✅', `Card verified successfully → ${Date.now() - verifyStartMs}ms total`, { outcome: identification.outcome.type });
|
|
||||||
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
|
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
|
||||||
} else {
|
} else {
|
||||||
debugLog('❌', `Verification failed (no outcome) → ${Date.now() - verifyStartMs}ms`);
|
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
cardTracker.negativeAt = Date.now();
|
cardTracker.negativeAt = Date.now();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
||||||
debugLog('💥', `Verification exception → ${Date.now() - verifyStartMs}ms`, { error: error.message });
|
|
||||||
cardTracker.status = 'negative';
|
cardTracker.status = 'negative';
|
||||||
cardTracker.negativeAt = Date.now();
|
cardTracker.negativeAt = Date.now();
|
||||||
reportScannerError(error.message || 'Scan failed');
|
reportScannerError(error.message || 'Scan failed');
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* 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`);
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* 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`);
|
|
||||||
};
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/**
|
|
||||||
* 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`);
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
7
package-lock.json
generated
|
|
@ -12,7 +12,6 @@
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
|
@ -6160,12 +6159,6 @@
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
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
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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
1192
pages/designer.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,329 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
1
vercel.json
Normal file
1
vercel.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
Loading…
Reference in a new issue