Files
magikarp-collection/src/app/page.tsx

296 lines
12 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from 'react';
import type { TcgCard } from '@/types/pokemon';
import { loadChecklistServer, saveChecklistServer } from '@/lib/checklist';
import type { ChecklistV2, VariantKey } 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 [checklist, setChecklist] = useState<ChecklistV2>({});
const [setId, setSetId] = useState('');
const [note, setNote] = useState<string | null>(null);
const [tab, setTab] = useState<'uncollected' | 'collected'>('uncollected');
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [cachedFlag, setCachedFlag] = useState<boolean | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
useEffect(() => {
(async () => {
const data = await loadChecklistServer();
setChecklist(data);
})();
}, []);
// Load reverse overrides at start
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/magikarp/override', { cache: 'no-store' });
if (!res.ok) return;
const json = await res.json();
const map = (json?.data?.reverseHolofoil || {}) as Record<string, boolean>;
if (map && typeof map === 'object') setOverrides(map);
} catch {}
})();
// Listen for UI-initiated override changes
function onOverrideChanged(ev: Event) {
try {
const ce = ev as CustomEvent<{ id: string; reverse: boolean }>;
const { id, reverse } = ce.detail || ({} as any);
if (!id) return;
setOverrides((prev) => ({ ...prev, [id]: !!reverse }));
} catch {}
}
window.addEventListener('reverse-override-changed', onOverrideChanged as EventListener);
return () => window.removeEventListener('reverse-override-changed', onOverrideChanged as EventListener);
}, []);
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();
const list: TcgCard[] = json.data || [];
list.sort((a, b) => {
const da = Date.parse(a.set.releaseDate || '');
const db = Date.parse(b.set.releaseDate || '');
if (Number.isFinite(da) && Number.isFinite(db)) {
if (da !== db) return da - db; // oldest first
} else if (Number.isFinite(da)) {
return -1;
} else if (Number.isFinite(db)) {
return 1;
}
// tie-breaker by card number if numeric
const na = parseInt(a.number, 10);
const nb = parseInt(b.number, 10);
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
return a.number.localeCompare(b.number);
});
setCards(list);
if (json.note) setNote(String(json.note));
if (json.updatedAt) setUpdatedAt(String(json.updatedAt));
if (typeof json.cached === 'boolean') setCachedFlag(json.cached);
} catch (e: any) {
setError(e?.message || 'Error');
} finally {
setLoading(false);
}
}
load();
}, []);
async function refreshCards() {
setRefreshing(true);
setError(null);
try {
const res = await fetch('/api/magikarp?pageSize=250&refresh=1', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to refresh');
const json = await res.json();
const list: TcgCard[] = json.data || [];
list.sort((a, b) => {
const da = Date.parse(a.set.releaseDate || '');
const db = Date.parse(b.set.releaseDate || '');
if (Number.isFinite(da) && Number.isFinite(db)) {
if (da !== db) return da - db; // oldest first
} else if (Number.isFinite(da)) {
return -1;
} else if (Number.isFinite(db)) {
return 1;
}
const na = parseInt(a.number, 10);
const nb = parseInt(b.number, 10);
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
return a.number.localeCompare(b.number);
});
setCards(list);
if (json.note) setNote(String(json.note)); else setNote(null);
if (json.updatedAt) setUpdatedAt(String(json.updatedAt));
if (typeof json.cached === 'boolean') setCachedFlag(json.cached);
} catch (e: any) {
// Non-blocking toast on refresh failure
const msg = e?.message ? String(e.message) : 'Refresh failed';
setToast(msg);
// auto-hide
setTimeout(() => setToast(null), 3000);
} finally {
setRefreshing(false);
}
}
function toggleCollected(id: string, key: VariantKey) {
setChecklist((prev) => {
const next: ChecklistV2 = { ...prev };
const current = { ...(next[id] || {}) };
current[key] = !current[key];
// Clean up empty records to keep storage tidy
const hasAny = Object.values(current).some(Boolean);
if (hasAny) next[id] = current; else delete next[id];
// fire-and-forget save to server; fallback handled in lib
void saveChecklistServer(next);
return next;
});
}
function availableVariants(card: TcgCard): VariantKey[] {
const list: VariantKey[] = ['base'];
const hasHolo = !!(card.variants?.holofoil || (card as any).tcgplayer?.prices?.holofoil);
const hasReverse = !!(card.variants?.reverseHolofoil || (card as any).tcgplayer?.prices?.reverseHolofoil) || !!overrides[card.id];
if (hasHolo) list.push('holofoil');
if (hasReverse) list.push('reverseHolofoil');
return list;
}
function isFullyCollected(card: TcgCard): boolean {
const needed = availableVariants(card);
const state = checklist[card.id] || {};
return needed.every((k) => !!state[k]);
}
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]);
// Variant-based counts for filtered set
const filteredCollectedVariantsCount = useMemo(() => {
return filtered.reduce((sum, c) => {
const state = checklist[c.id] || {};
return sum + availableVariants(c).reduce((acc, k) => acc + (state[k] ? 1 : 0), 0);
}, 0);
}, [filtered, checklist]);
const filteredTotalVariants = useMemo(() => filtered.reduce((sum, c) => sum + availableVariants(c).length, 0), [filtered]);
const filteredUncollectedVariantsCount = useMemo(() => filteredTotalVariants - filteredCollectedVariantsCount, [filteredTotalVariants, filteredCollectedVariantsCount]);
const displayed = useMemo(() => {
return tab === 'collected'
? filtered.filter((c) => isFullyCollected(c))
: filtered.filter((c) => !isFullyCollected(c));
}, [filtered, checklist, tab]);
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]);
// Overall variant-based totals for header
const totalVariantsAll = useMemo(() => cards.reduce((sum, c) => sum + availableVariants(c).length, 0), [cards]);
const collectedVariantsAll = useMemo(() => cards.reduce((sum, c) => {
const state = checklist[c.id] || {};
return sum + availableVariants(c).reduce((acc, k) => acc + (state[k] ? 1 : 0), 0);
}, 0), [cards, checklist]);
return (
<main className="container mx-auto">
<Header
query={query}
onQueryChange={setQuery}
total={totalVariantsAll}
collectedCount={collectedVariantsAll}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<SetFilter options={setOptions} value={setId} onChange={setSetId} />
<button
type="button"
onClick={refreshCards}
disabled={refreshing}
className={`px-3 py-1.5 text-sm rounded border transition ${refreshing ? 'bg-slate-200 text-slate-500 border-slate-300' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'}`}
aria-busy={refreshing}
>
{refreshing ? 'Refreshing…' : 'Refresh cards'}
</button>
{updatedAt ? (
<span className="text-xs text-slate-500 flex items-center gap-2">
Updated: {new Date(updatedAt).toLocaleString()}
{cachedFlag !== null && (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[0.7rem] border ${cachedFlag ? 'bg-yellow-50 text-yellow-800 border-yellow-200' : 'bg-green-50 text-green-800 border-green-200'}`}>
{cachedFlag ? 'Cached' : 'Live'}
</span>
)}
</span>
) : null}
</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-3 flex items-center gap-2">
<button
type="button"
onClick={() => setTab('uncollected')}
className={`px-3 py-1.5 text-sm rounded border transition ${
tab === 'uncollected'
? 'bg-sky-600 text-white border-sky-600'
: 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'
}`}
aria-pressed={tab === 'uncollected'}
>
Uncollected <span className="ml-1 inline-block rounded bg-white/20 px-1.5 py-0.5 text-xs border border-white/30">
{filteredUncollectedVariantsCount}
</span>
</button>
<button
type="button"
onClick={() => setTab('collected')}
className={`px-3 py-1.5 text-sm rounded border transition ${
tab === 'collected'
? 'bg-sky-600 text-white border-sky-600'
: 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'
}`}
aria-pressed={tab === 'collected'}
>
Collected <span className="ml-1 inline-block rounded bg-white/20 px-1.5 py-0.5 text-xs border border-white/30">
{filteredCollectedVariantsCount}
</span>
</button>
</div>
<div className="mb-4 text-sm text-slate-600">
Showing {displayed.length} of {filtered.length} cards in this tab.
</div>
<CardGrid cards={displayed} checklist={checklist} onToggle={toggleCollected} />
{/* Toast */}
{toast ? (
<div className="fixed bottom-4 right-4 z-50 rounded-md bg-slate-900 text-white px-3 py-2 text-sm shadow-lg">
{toast}
</div>
) : null}
</>
)}
</main>
);
}