Init commit from initial prompt

This commit is contained in:
2025-08-17 12:21:15 +01:00
commit caee8e27f2
21 changed files with 3114 additions and 0 deletions

View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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;
};