Switch card data source from pokemontcg.io to TCGdex

The Pokémon TCG API is deprecated (no new keys, existing keys end 2027-03-01)
and was returning 5xx errors. TCGdex is free and needs no API key.

- src/lib/api.ts: fetch the card list, then each card and set, with retries
  and limited concurrency; map to the existing TcgCard shape. Pokémon TCG
  Pocket cards are skipped.
- Keep the old card IDs for the 10 cards whose TCGdex ID differs, so saved
  checklists stay valid (verified by name, set and number).
- Fall back to the old image host for cards TCGdex has no scan for, and to a
  placeholder if an image fails to load.
- route.ts: serve the last cached data (marked stale) when a refresh fails;
  drop the API key handling.
- Remove the API key variables from compose and the env example; update README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 13:14:25 +00:00
parent d13a4f1463
commit 1612834577
8 changed files with 166 additions and 61 deletions

View File

@@ -1,11 +1,4 @@
# Get a free API key at https://dev.pokemontcg.io/
# Then copy this file to .env.local and paste your key(s) below
# Client-side API key (exposed to browser)
NEXT_PUBLIC_POKEMON_TCG_API_KEY=
# Optional: Server-side API key (preferred). If set, server uses this and does not rely on public key.
POKEMON_TCG_API_KEY=
# No API key is needed: card data comes from the free TCGdex API (https://tcgdex.dev/).
# Optional: Enable very simple single-user Basic Auth (set both to enable)
BASIC_AUTH_USER=

View File

@@ -4,8 +4,8 @@ A Next.js web app to browse every English Magikarp Pokémon card with images, se
## Features
- **Checklist (variants-aware)**: Track base, holofoil, and reverse holofoil per card. Local persistence with migration from v1 to v2.
- **Card images**: Uses official images from `images.pokemontcg.io`.
- **Set symbols**: Displays set symbol and series/name.
- **Card images**: Card scans from `assets.tcgdex.net` (a placeholder is shown for cards TCGdex has no scan for).
- **Set badges**: Displays series and set name.
- **Search & filter**: Search by name/number/rarity/set and filter by set.
- **Totals include variants**: The header and tab badges use variant totals (base + available variants) for collected/uncollected counts.
- **Sort by release date**: Cards are ordered by set release date (oldest first).
@@ -32,15 +32,14 @@ npm install
```
### 3) Environment variables
Get a free Pokémon TCG API key: https://dev.pokemontcg.io/
No API key is needed. Card data comes from the free [TCGdex](https://tcgdex.dev/) API.
Create one of the following files in the project root and add your key:
- `.env.local` (recommended for Next.js)
- `.env` (also supported)
To enable the optional single-user Basic Auth, create `.env.local` (or `.env`) in the project root:
```bash
# .env.local
NEXT_PUBLIC_POKEMON_TCG_API_KEY=your_key_here
BASIC_AUTH_USER=your_user
BASIC_AUTH_PASS=your_password
```
An example is provided in `.env.local.example`.
@@ -97,13 +96,15 @@ magikarp-collection/
```
## Data Source
- Pokémon TCG API v2: https://api.pokemontcg.io/
- Endpoint used: `GET /v2/cards` with query `q=name:magikarp`
- API key header: `X-Api-Key: <your key>` (automatically added when `NEXT_PUBLIC_POKEMON_TCG_API_KEY` is present)
- TCGdex REST API v2: https://api.tcgdex.net/v2/en (no key required)
- `GET /cards?name=magikarp` lists matching cards; each card (`/cards/{id}`) and each set (`/sets/{id}`) is then fetched for details.
- Pokémon TCG Pocket cards are skipped.
- Cards whose TCGdex ID differs from the old pokemontcg.io ID keep the old ID (see `LEGACY_CARDS` in `src/lib/api.ts`) so saved checklists stay valid.
- The previous source, the Pokémon TCG API (pokemontcg.io), is deprecated.
## How It Works
- The client loads data from `GET /api/magikarp` (`src/app/api/magikarp/route.ts`).
- The route proxies to Pokémon TCG API using `fetchMagikarpCards()` (`src/lib/api.ts`), caches the full response in `data/cards.json`, and serves cached data when fresh (<24h) unless `?refresh=1` is provided.
- The route fetches from TCGdex using `fetchMagikarpCards()` (`src/lib/api.ts`), caches the full response in `data/cards.json`, and serves cached data when fresh (<24h) unless `?refresh=1` is provided. If a refresh fails, the last cached data is served and marked `stale`.
- Cards render in `CardGrid`/`CardItem`, ordered by set release date.
- Checklist persists locally (v2 supports variant-level tracking). A server-backed route exists at `api/checklist` if you enable it.
@@ -127,7 +128,7 @@ Open: http://localhost:3000 (or your chosen `APP_PORT`).
- `data/cards.json` and `data/checklist.json` are gitignored by default.
## Deployment
- **Vercel**: Zero-config for Next.js. Add `NEXT_PUBLIC_POKEMON_TCG_API_KEY` in project Environment Variables.
- **Vercel**: Zero-config for Next.js. No API key is required.
- **Netlify/Other**: Build command `npm run build`, publish `.next` with a Next adapter or use Next on Node; make sure environment vars are set.
## Troubleshooting
@@ -137,8 +138,8 @@ Open: http://localhost:3000 (or your chosen `APP_PORT`).
# Ensure postcss.config.cjs exists with:
# module.exports = { plugins: { '@tailwindcss/postcss': {} } }
```
- Images not loading: confirm `next.config.ts` allows `images.pokemontcg.io` and restart the dev server.
- No cards shown: ensure your API key is valid and in `.env.local` (or `.env`). Restart the dev server after changes.
- Images not loading: confirm `next.config.ts` allows `assets.tcgdex.net` and restart the dev server.
- No cards shown: check the server log for `Error fetching cards`; if TCGdex is unreachable the app serves the last cached data.
## License
This project is provided as-is for personal collection tracking.

View File

@@ -10,9 +10,6 @@ services:
- NODE_ENV=production
# Internal Next.js port (container). Do not change unless you change Dockerfile.
- PORT=3000
# Forward any API keys if needed
- NEXT_PUBLIC_POKEMON_TCG_API_KEY=${NEXT_PUBLIC_POKEMON_TCG_API_KEY}
- POKEMON_TCG_API_KEY=${POKEMON_TCG_API_KEY}
# Optional: enable very simple single-user Basic Auth
- BASIC_AUTH_USER=${BASIC_AUTH_USER}
- BASIC_AUTH_PASS=${BASIC_AUTH_PASS}

View File

@@ -3,6 +3,7 @@ import { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'assets.tcgdex.net' },
{ protocol: 'https', hostname: 'images.pokemontcg.io' },
{ protocol: 'https', hostname: 'api.pokemontcg.io' },
],

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 420" role="img" aria-label="No card image available">
<rect x="6" y="6" width="288" height="408" rx="18" fill="#e2e8f0" stroke="#94a3b8" stroke-width="4"/>
<circle cx="150" cy="190" r="46" fill="none" stroke="#94a3b8" stroke-width="6"/>
<line x1="104" y1="190" x2="196" y2="190" stroke="#94a3b8" stroke-width="6"/>
<circle cx="150" cy="190" r="14" fill="#e2e8f0" stroke="#94a3b8" stroke-width="6"/>
<text x="150" y="300" text-anchor="middle" font-family="sans-serif" font-size="22" fill="#64748b">No image</text>
</svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -22,11 +22,8 @@ function applyReverseOverrides(payload: any, reverseMap: Record<string, boolean>
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q') ?? undefined;
const page = Number(searchParams.get('page') ?? '1');
const pageSize = Number(searchParams.get('pageSize') ?? '250');
const orderBy = searchParams.get('orderBy') ?? 'set.releaseDate';
const refresh = searchParams.get('refresh') === '1';
let reverseMap: Record<string, boolean> = {};
try {
await ensureDataDir();
@@ -36,7 +33,7 @@ export async function GET(req: NextRequest) {
const rawOv = await fs.readFile(OVERRIDES_FILE, 'utf-8');
overrides = rawOv ? JSON.parse(rawOv) : {};
} catch {}
const reverseMap = overrides?.reverseHolofoil || {};
reverseMap = overrides?.reverseHolofoil || {};
if (!refresh) {
// Try to serve from cache if exists
try {
@@ -53,7 +50,7 @@ export async function GET(req: NextRequest) {
}
// Fetch fresh from API
const data = await fetchMagikarpCards({ q, page, pageSize });
const data = await fetchMagikarpCards();
// Apply overrides to fresh data
const patched = applyReverseOverrides(data, reverseMap);
const payload = { ...patched, cached: false, updatedAt: new Date().toISOString() };
@@ -61,14 +58,18 @@ export async function GET(req: NextRequest) {
try { await fs.writeFile(CARDS_FILE, JSON.stringify(payload, null, 2), 'utf-8'); } catch {}
return Response.json(payload, { status: 200 });
} catch (err: any) {
const hasKey = !!(process.env.POKEMON_TCG_API_KEY || process.env.NEXT_PUBLIC_POKEMON_TCG_API_KEY);
console.error('Error fetching cards', err);
if (!hasKey) {
return Response.json(
{ data: [], note: 'No API key configured. Create .env.local with NEXT_PUBLIC_POKEMON_TCG_API_KEY="<your key>"' },
{ status: 200 }
);
}
// Prefer showing the last good cache (however old) over an empty page
try {
const raw = await fs.readFile(CARDS_FILE, 'utf-8');
if (raw) {
const json = applyReverseOverrides(JSON.parse(raw), reverseMap);
return Response.json(
{ ...json, cached: true, stale: true, note: `Could not refresh cards, showing cached data: ${err}` },
{ status: 200 }
);
}
} catch {}
return Response.json({ error: `Failed to fetch cards: ${err}` }, { status: 500 });
}
}

View File

@@ -9,6 +9,7 @@ import { dispatchReverseOverride, loadOverridesClient } from '@/lib/overrides';
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: VariantState; onToggle: (id:string, key: VariantKey)=>void }) {
const rate = useUsdGbpRate();
const [imageFailed, setImageFailed] = React.useState<boolean>(false);
const hasHolo = !!(card.variants?.holofoil || (card.tcgplayer as any)?.prices?.holofoil);
const hasReverseDetected = !!(card.variants?.reverseHolofoil || (card.tcgplayer as any)?.prices?.reverseHolofoil);
const [reverseOverride, setReverseOverride] = React.useState<boolean>(false);
@@ -77,7 +78,8 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm hover:shadow-md transition">
<div className="relative aspect-[3/4] w-full bg-slate-100">
<Image
src={card.images.small}
src={imageFailed ? '/card-placeholder.svg' : card.images.small}
onError={() => setImageFailed(true)}
alt={`${card.name} #${card.number}`}
fill
className="object-contain p-3"

View File

@@ -1,30 +1,133 @@
import axios from 'axios';
import type { CardsResponse } from '@/types/pokemon';
import type { CardsResponse, TcgCard } from '@/types/pokemon';
const BASE = 'https://api.pokemontcg.io/v2';
// TCGdex: free, no API key. https://tcgdex.dev/rest
const BASE = 'https://api.tcgdex.net/v2/en';
const PLACEHOLDER_IMAGE = '/card-placeholder.svg';
const CONCURRENCY = 6;
const ATTEMPTS = 3;
export type FetchCardsParams = {
q?: string;
page?: number;
pageSize?: number;
// Pokémon TCG Pocket is a mobile game; its cards are not part of the physical collection.
const EXCLUDED_SERIES = new Set(['tcgp']);
// TCGdex IDs differ from the IDs the previous API (pokemontcg.io) used. Saved checklists are keyed
// by the old IDs, so these cards keep them. Each pairing was checked by name, set and number.
const LEGACY_CARDS: Record<string, { id: string; number?: string }> = {
'2016xy-4': { id: 'mcd16-4' },
'sm7.5-19': { id: 'sm75-19' },
'lc-52': { id: 'base6-52' },
'cel25cc-CC010': { id: 'cel25c-66_A', number: '66' },
'swsh10.5-021': { id: 'pgo-21' },
'sv01-044': { id: 'sv1-44' },
'sv02-042': { id: 'sv2-42' },
'sv02-203': { id: 'sv2-203' },
'sv03.5-129': { id: 'sv3pt5-129' },
'sv10-048': { id: 'sv10-48' },
};
export async function fetchMagikarpCards(params: FetchCardsParams = {}): Promise<CardsResponse> {
const { q, page = 1, pageSize = 50 } = params;
// Keep query minimal to avoid parser errors on API side
const parts = [
'name:magikarp',
];
if (q) parts.push(q);
type BriefCard = { id: string; localId: string; name: string };
const url = `${BASE}/cards`;
const headers: Record<string, string> = {};
const apiKey = process.env.POKEMON_TCG_API_KEY || process.env.NEXT_PUBLIC_POKEMON_TCG_API_KEY;
if (apiKey) headers['X-Api-Key'] = apiKey;
type FullCard = BriefCard & {
image?: string;
rarity?: string;
set: { id: string; name: string };
variants?: { normal?: boolean; reverse?: boolean; holo?: boolean };
};
const { data } = await axios.get<CardsResponse>(url, {
params: { q: parts.join(' '), page, pageSize },
headers,
});
return data;
type FullSet = {
id: string;
name: string;
releaseDate?: string;
serie?: { id: string; name: string };
cardCount?: { official?: number; total?: number };
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// The API occasionally answers 5xx; retry those, fail fast on anything else.
async function get<T>(path: string, params?: Record<string, string>): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
const { data } = await axios.get<T>(`${BASE}${path}`, { params, timeout: 20000 });
return data;
} catch (err) {
lastErr = err;
const status = axios.isAxiosError(err) ? err.response?.status : undefined;
if (status !== undefined && status < 500) break;
if (attempt < ATTEMPTS) await sleep(500 * attempt);
}
}
throw lastErr;
}
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function tidyRarity(rarity?: string): string | undefined {
if (!rarity || rarity === 'None') return undefined;
return rarity.replace(/\b[a-z]/g, (ch) => ch.toUpperCase());
}
// Some cards have no scan on TCGdex. The old image host still serves the ones we tracked before.
function legacyImages(legacyId: string) {
const [setId, ...rest] = legacyId.split('-');
const url = `https://images.pokemontcg.io/${setId}/${rest.join('-')}`;
return { small: `${url}.png`, large: `${url}_hires.png` };
}
function toTcgCard(card: FullCard, set: FullSet): TcgCard {
const legacy = LEGACY_CARDS[card.id];
return {
id: legacy?.id ?? card.id,
name: card.name,
number: legacy?.number ?? card.localId.replace(/^0+(?=\d)/, ''),
rarity: tidyRarity(card.rarity),
images: card.image
? { small: `${card.image}/low.webp`, large: `${card.image}/high.webp` }
: legacy
? legacyImages(legacy.id)
: { small: PLACEHOLDER_IMAGE, large: PLACEHOLDER_IMAGE },
set: {
id: set.id,
name: set.name,
series: set.serie?.name ?? '',
releaseDate: set.releaseDate,
printedTotal: set.cardCount?.official,
total: set.cardCount?.total,
// TCGdex set symbol URLs currently return 400, so the badge is text-only
},
variants: {
holofoil: !!card.variants?.holo,
reverseHolofoil: !!card.variants?.reverse,
},
};
}
// Throws if any request fails, so a partial result is never cached.
export async function fetchMagikarpCards(): Promise<CardsResponse> {
const brief = await get<BriefCard[]>('/cards', { name: 'magikarp' });
const matches = brief.filter((c) => c.name.toLowerCase().includes('magikarp'));
const cards = await mapLimit(matches, CONCURRENCY, (c) => get<FullCard>(`/cards/${encodeURIComponent(c.id)}`));
const setIds = Array.from(new Set(cards.map((c) => c.set.id)));
const setList = await mapLimit(setIds, CONCURRENCY, (id) => get<FullSet>(`/sets/${encodeURIComponent(id)}`));
const sets = new Map(setList.map((s) => [s.id, s]));
const data = cards
.filter((c) => !EXCLUDED_SERIES.has(sets.get(c.set.id)?.serie?.id ?? ''))
.map((c) => toTcgCard(c, sets.get(c.set.id) ?? { id: c.set.id, name: c.set.name }));
return { data, page: 1, pageSize: data.length, count: data.length, totalCount: data.length };
}