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:
@@ -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=
|
||||
|
||||
29
README.md
29
README.md
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
7
public/card-placeholder.svg
Normal file
7
public/card-placeholder.svg
Normal 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 |
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
147
src/lib/api.ts
147
src/lib/api.ts
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user