Init commit from initial prompt
This commit is contained in:
3
.env.local.example
Normal file
3
.env.local.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Get a free API key at https://dev.pokemontcg.io/
|
||||||
|
# Then copy this file to .env.local and paste your key below
|
||||||
|
NEXT_PUBLIC_POKEMON_TCG_API_KEY=
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Debug logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Environment files (keep examples tracked)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Testing and coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.turbo/
|
||||||
|
.cache/
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Deployment/platform
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Editors/IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS-specific
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Keep Next.js type helper tracked
|
||||||
|
!next-env.d.ts
|
||||||
114
README.md
Normal file
114
README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Magikarp Collection
|
||||||
|
|
||||||
|
A Next.js web app to browse every English Magikarp Pokémon card with images, set symbols, search, set filter, and a personal checklist stored in your browser.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Checklist**: Mark cards as collected (stored in `localStorage`).
|
||||||
|
- **Card images**: Uses official images from `images.pokemontcg.io`.
|
||||||
|
- **Set symbols**: Displays set symbol and series/name.
|
||||||
|
- **Search & filter**: Search by name/number/rarity/set and filter by set.
|
||||||
|
- **Responsive UI**: Tailwind CSS, mobile-friendly grid.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Next.js 15** (App Router)
|
||||||
|
- **React 19**
|
||||||
|
- **TypeScript**
|
||||||
|
- **Tailwind CSS v4**
|
||||||
|
- **Axios** for HTTP
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1) Prerequisites
|
||||||
|
- Node.js 18+ and npm
|
||||||
|
|
||||||
|
### 2) Install dependencies
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Environment variables
|
||||||
|
Get a free Pokémon TCG API key: https://dev.pokemontcg.io/
|
||||||
|
|
||||||
|
Create one of the following files in the project root and add your key:
|
||||||
|
- `.env.local` (recommended for Next.js)
|
||||||
|
- `.env` (also supported)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
NEXT_PUBLIC_POKEMON_TCG_API_KEY=your_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
An example is provided in `.env.local.example`.
|
||||||
|
|
||||||
|
### 4) Run the dev server
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
By default: http://localhost:3000
|
||||||
|
|
||||||
|
If you see a server already running on port 3000, you can use another port:
|
||||||
|
```bash
|
||||||
|
PORT=3001 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
- `npm run dev` – start Next.js in development
|
||||||
|
- `npm run build` – production build
|
||||||
|
- `npm run start` – start production server
|
||||||
|
- `npm run lint` – lint project
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
magikarp-collection/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ app/
|
||||||
|
│ │ ├─ api/
|
||||||
|
│ │ │ └─ magikarp/route.ts # API route that fetches cards from Pokémon TCG API
|
||||||
|
│ │ ├─ layout.tsx # Root layout and global wrapper
|
||||||
|
│ │ ├─ page.tsx # Main page: search, set filter, grid, checklist
|
||||||
|
│ │ └─ globals.css # Tailwind entry + global styles
|
||||||
|
│ ├─ components/
|
||||||
|
│ │ ├─ CardGrid.tsx # Grid for card list
|
||||||
|
│ │ ├─ CardItem.tsx # Card tile with image, rarity, set badge, checklist toggle
|
||||||
|
│ │ ├─ Header.tsx # Title, search, stats
|
||||||
|
│ │ ├─ SetBadge.tsx # Shows set symbol and name
|
||||||
|
│ │ └─ SetFilter.tsx # Dropdown to filter by set
|
||||||
|
│ ├─ lib/
|
||||||
|
│ │ ├─ api.ts # Axios client for Pokémon TCG API
|
||||||
|
│ │ └─ checklist.ts # localStorage-backed checklist utils
|
||||||
|
│ └─ types/
|
||||||
|
│ └─ pokemon.ts # TypeScript types for cards/sets
|
||||||
|
├─ next.config.ts # Remote image domains, turbopack settings
|
||||||
|
├─ tsconfig.json # TS config with path alias '@/*'
|
||||||
|
├─ postcss.config.cjs # Tailwind v4 PostCSS plugin config
|
||||||
|
├─ package.json
|
||||||
|
└─ .env.local.example # Example env file
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
- The client loads data from the internal Next.js route `GET /api/magikarp` (`src/app/api/magikarp/route.ts`).
|
||||||
|
- The route proxies to the Pokémon TCG API using `fetchMagikarpCards()` (`src/lib/api.ts`).
|
||||||
|
- Cards are displayed in `CardGrid`/`CardItem` with images and set symbols (`SetBadge`).
|
||||||
|
- Checklist state is persisted locally via `localStorage` (`src/lib/checklist.ts`).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- **Vercel**: Zero-config for Next.js. Add `NEXT_PUBLIC_POKEMON_TCG_API_KEY` in project Environment Variables.
|
||||||
|
- **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
|
||||||
|
- "Cannot find module '@tailwindcss/postcss'": install the Tailwind v4 PostCSS plugin and ensure CommonJS config.
|
||||||
|
```bash
|
||||||
|
npm i -D @tailwindcss/postcss
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is provided as-is for personal collection tracking.
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
17
next.config.ts
Normal file
17
next.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: 'https', hostname: 'images.pokemontcg.io' },
|
||||||
|
{ protocol: 'https', hostname: 'api.pokemontcg.io' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
turbo: {
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
2444
package-lock.json
generated
Normal file
2444
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "magikarp-collection",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.7",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
|
"next": "^15.4.6",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-intersection-observer": "^9.16.0",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.cjs
Normal file
5
postcss.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
25
src/app/api/magikarp/route.ts
Normal file
25
src/app/api/magikarp/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { fetchMagikarpCards } from '@/lib/api';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchMagikarpCards({ q, page, pageSize });
|
||||||
|
return Response.json(data, { 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response.json({ error: 'Failed to fetch cards' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/globals.css
Normal file
15
src/app/globals.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: 248 250 252;
|
||||||
|
--fg: 15 23 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility tweaks */
|
||||||
|
.container {
|
||||||
|
max-width: 80rem;
|
||||||
|
}
|
||||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Magikarp Collection',
|
||||||
|
description: 'Checklist and gallery for all English Magikarp Pokémon cards',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full bg-slate-50">
|
||||||
|
<body className="min-h-full text-slate-800">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/page.tsx
Normal file
106
src/app/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { TcgCard } from '@/types/pokemon';
|
||||||
|
import { loadChecklist, saveChecklist } from '@/lib/checklist';
|
||||||
|
import CardGrid from '@/components/CardGrid';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import SetFilter from '@/components/SetFilter';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [cards, setCards] = useState<TcgCard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [collected, setCollected] = useState<Set<string>>(new Set());
|
||||||
|
const [setId, setSetId] = useState('');
|
||||||
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollected(loadChecklist());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/magikarp?pageSize=250');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
const json = await res.json();
|
||||||
|
setCards(json.data || []);
|
||||||
|
if (json.note) setNote(String(json.note));
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleCollected(id: string) {
|
||||||
|
setCollected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
saveChecklist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const byQuery = (() => {
|
||||||
|
if (!query.trim()) return cards;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return cards.filter((c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.number.toLowerCase().includes(q) ||
|
||||||
|
(c.rarity || '').toLowerCase().includes(q) ||
|
||||||
|
c.set.name.toLowerCase().includes(q) ||
|
||||||
|
c.set.series.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
const bySet = setId ? byQuery.filter((c) => c.set.id === setId) : byQuery;
|
||||||
|
return bySet;
|
||||||
|
}, [cards, query, setId]);
|
||||||
|
|
||||||
|
const setOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of cards) {
|
||||||
|
if (!map.has(c.set.id)) map.set(c.set.id, `${c.set.series} — ${c.set.name}`);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [cards]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto">
|
||||||
|
<Header
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
total={cards.length}
|
||||||
|
collectedCount={collected.size}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<SetFilter options={setOptions} value={setId} onChange={setSetId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-slate-600">Loading Magikarp cards…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{note ? (
|
||||||
|
<div className="mb-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-800 text-sm">
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mb-4 text-sm text-slate-600">
|
||||||
|
Showing {filtered.length} of {cards.length} cards.
|
||||||
|
</div>
|
||||||
|
<CardGrid cards={filtered} collected={collected} onToggle={toggleCollected} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/CardGrid.tsx
Normal file
27
src/components/CardGrid.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
import type { TcgCard } from '@/types/pokemon';
|
||||||
|
import CardItem from './CardItem';
|
||||||
|
|
||||||
|
export default function CardGrid({
|
||||||
|
cards,
|
||||||
|
collected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
cards: TcgCard[];
|
||||||
|
collected: Set<string>;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<CardItem
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
checked={collected.has(card.id)}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/CardItem.tsx
Normal file
40
src/components/CardItem.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
import type { TcgCard } from '@/types/pokemon';
|
||||||
|
import SetBadge from './SetBadge';
|
||||||
|
|
||||||
|
export default function CardItem({ card, checked, onToggle }:{ card: TcgCard; checked: boolean; onToggle: (id:string)=>void }) {
|
||||||
|
return (
|
||||||
|
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm hover:shadow-md transition">
|
||||||
|
<div className="absolute right-2 top-2 z-10">
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs bg-white/90 px-2 py-1 rounded shadow border border-slate-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggle(card.id)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
aria-label={`Mark ${card.name} #${card.number} as collected`}
|
||||||
|
/>
|
||||||
|
<span>Collected</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="relative aspect-[3/4] w-full bg-slate-100">
|
||||||
|
<Image
|
||||||
|
src={card.images.small}
|
||||||
|
alt={`${card.name} #${card.number}`}
|
||||||
|
fill
|
||||||
|
className="object-contain p-3"
|
||||||
|
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 20vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium truncate" title={`${card.name} #${card.number}`}>{card.name} <span className="text-slate-500">#{card.number}</span></h3>
|
||||||
|
{card.rarity ? <span className="text-[0.7rem] rounded bg-slate-100 px-2 py-0.5 text-slate-600">{card.rarity}</span> : null}
|
||||||
|
</div>
|
||||||
|
<SetBadge set={card.set} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/Header.tsx
Normal file
33
src/components/Header.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
total,
|
||||||
|
collectedCount,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (v: string) => void;
|
||||||
|
total: number;
|
||||||
|
collectedCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Magikarp Collection</h1>
|
||||||
|
<p className="text-sm text-slate-600">Track every English Magikarp card. Collected {collectedCount}/{total}.</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 shadow-sm w-full sm:w-80">
|
||||||
|
<MagnifyingGlassIcon className="h-5 w-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder="Search set, rarity, number..."
|
||||||
|
className="w-full bg-transparent outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/SetBadge.tsx
Normal file
22
src/components/SetBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
import type { TcgSet } from '@/types/pokemon';
|
||||||
|
|
||||||
|
export default function SetBadge({ set }: { set: TcgSet }) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm">
|
||||||
|
{set.images?.symbol ? (
|
||||||
|
<Image
|
||||||
|
src={set.images.symbol}
|
||||||
|
alt={`${set.name} symbol`}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate max-w-[10rem]" title={`${set.series} — ${set.name}`}>
|
||||||
|
{set.series} — {set.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/SetFilter.tsx
Normal file
30
src/components/SetFilter.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type SetOption = { id: string; label: string };
|
||||||
|
|
||||||
|
export default function SetFilter({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: SetOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-slate-600">Set</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-2 py-1 shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">All sets</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/lib/api.ts
Normal file
30
src/lib/api.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { CardsResponse } from '@/types/pokemon';
|
||||||
|
|
||||||
|
const BASE = 'https://api.pokemontcg.io/v2';
|
||||||
|
|
||||||
|
export type FetchCardsParams = {
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const { data } = await axios.get<CardsResponse>(url, {
|
||||||
|
params: { q: parts.join(' '), page, pageSize },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
22
src/lib/checklist.ts
Normal file
22
src/lib/checklist.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const CHECKLIST_KEY = 'magikarp-checklist-v1';
|
||||||
|
|
||||||
|
export function loadChecklist(): Set<string> {
|
||||||
|
if (typeof window === 'undefined') return new Set();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CHECKLIST_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
return new Set(arr);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChecklist(set: Set<string>) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHECKLIST_KEY, JSON.stringify(Array.from(set)));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/types/pokemon.ts
Normal file
31
src/types/pokemon.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type TcgSet = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
series: string;
|
||||||
|
printedTotal?: number;
|
||||||
|
total?: number;
|
||||||
|
images?: {
|
||||||
|
symbol?: string;
|
||||||
|
logo?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TcgCard = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
rarity?: string;
|
||||||
|
images: {
|
||||||
|
small: string;
|
||||||
|
large: string;
|
||||||
|
};
|
||||||
|
set: TcgSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardsResponse = {
|
||||||
|
data: TcgCard[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
count?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
};
|
||||||
46
tsconfig.json
Normal file
46
tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"*.ts",
|
||||||
|
"*.tsx",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src/**/*",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user