diff --git a/.env.local.example b/.env.local.example index c5a2137..e8b7e6f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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= diff --git a/README.md b/README.md index da64b9c..2622146 100644 --- a/README.md +++ b/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: ` (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. diff --git a/docker-compose.yml b/docker-compose.yml index 9b8b57b..2b3588a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/next.config.ts b/next.config.ts index b11dbe9..fbd89cb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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' }, ], diff --git a/public/card-placeholder.svg b/public/card-placeholder.svg new file mode 100644 index 0000000..e0c1cdb --- /dev/null +++ b/public/card-placeholder.svg @@ -0,0 +1,7 @@ + + + + + + No image + diff --git a/src/app/api/magikarp/route.ts b/src/app/api/magikarp/route.ts index 9cf3723..cfac05a 100644 --- a/src/app/api/magikarp/route.ts +++ b/src/app/api/magikarp/route.ts @@ -22,11 +22,8 @@ function applyReverseOverrides(payload: any, reverseMap: Record 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 = {}; 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=""' }, - { 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 }); } } diff --git a/src/components/CardItem.tsx b/src/components/CardItem.tsx index 61cafd2..60c4e6f 100644 --- a/src/components/CardItem.tsx +++ b/src/components/CardItem.tsx @@ -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(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(false); @@ -77,7 +78,8 @@ export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; ch
setImageFailed(true)} alt={`${card.name} #${card.number}`} fill className="object-contain p-3" diff --git a/src/lib/api.ts b/src/lib/api.ts index e932914..c856a25 100644 --- a/src/lib/api.ts +++ b/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 = { + '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 { - 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 = {}; - 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(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(path: string, params?: Record): Promise { + let lastErr: unknown; + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const { data } = await axios.get(`${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(items: T[], limit: number, fn: (item: T) => Promise): Promise { + 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 { + const brief = await get('/cards', { name: 'magikarp' }); + const matches = brief.filter((c) => c.name.toLowerCase().includes('magikarp')); + + const cards = await mapLimit(matches, CONCURRENCY, (c) => get(`/cards/${encodeURIComponent(c.id)}`)); + + const setIds = Array.from(new Set(cards.map((c) => c.set.id))); + const setList = await mapLimit(setIds, CONCURRENCY, (id) => get(`/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 }; }