296 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|