Add Sets page, React Router navigation, collection export, and README
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-16 00:51:45 +02:00
parent 6aa3dcf41b
commit 1b91a0cc3c
8 changed files with 357 additions and 25 deletions
+14 -1
View File
@@ -1,8 +1,21 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import NavBar from './components/NavBar/NavBar';
import HomePage from './pages/HomePage';
import SetsPage from './pages/SetsPage';
function App() {
return <HomePage />;
return (
<BrowserRouter>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<NavBar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/sets" element={<SetsPage />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
function NavBar() {
return (
<nav style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '0 16px', background: '#111', borderBottom: '1px solid #2a2a2a',
flexShrink: 0,
}}>
{[
{ to: '/', label: 'Cards' },
{ to: '/sets', label: 'Sets' },
].map(({ to, label }) => (
<NavLink
key={to}
to={to}
end
style={({ isActive }) => ({
padding: '10px 16px',
fontSize: '13px',
color: isActive ? '#e0e0e0' : '#555',
textDecoration: 'none',
borderBottom: isActive ? '2px solid #e0e0e0' : '2px solid transparent',
transition: 'color 0.15s',
})}
>
{label}
</NavLink>
))}
</nav>
);
}
export default NavBar;
+24 -1
View File
@@ -38,6 +38,25 @@ function HomePage() {
const [sortBy, setSortBy] = useState('name');
const { expandedCardId, setExpandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext);
const exportCollection = useCallback(() => {
const data = cards.flatMap(card =>
(card.printings ?? [])
.map(p => {
const key = `${p.set_id}-${p.rarity_id}`;
const amount = ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0;
return amount > 0 ? { card_id: card.id, card_name: card.name, set_id: p.set_id, set_name: p.set_name, set_code: p.set_code, rarity_id: p.rarity_id, rarity_name: p.rarity_name, amount_owned: amount } : null;
})
.filter(Boolean)
);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `yugioh-collection-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [cards, ownedAmounts]);
const [artworkIndex, setArtworkIndex] = useState(0);
const isMobile = useMediaQuery('(max-width: 768px)');
const sheetRef = React.useRef(null);
@@ -210,7 +229,11 @@ function HomePage() {
<span><strong style={{ color: '#e0e0e0' }}>{stats.totalCopies.toLocaleString()}</strong> total copies</span>
</>
)}
<div style={{ marginLeft: 'auto' }}>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={exportCollection}
style={{ background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a', borderRadius: '5px', padding: '3px 10px', fontSize: '12px', cursor: 'pointer' }}
>Export</button>
<Footer onImportComplete={loadCards} />
</div>
</div>
+170
View File
@@ -0,0 +1,170 @@
import React, { useEffect, useState, useMemo, useContext } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { fetchSets, fetchSetCards, updateCardAmount } from '../services/api';
import { CardContext } from '../context/CardContext';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { fuzzyMatch } from '../utils/search';
import SearchBar from '../components/SearchBar/SearchBar';
function SetRow({ set, isSelected, onClick }) {
const pct = set.total_in_db > 0 ? Math.round((set.owned_count / set.total_in_db) * 100) : 0;
return (
<div
onClick={onClick}
style={{
padding: '10px 16px', borderBottom: '1px solid #1e1e1e', cursor: 'pointer',
background: isSelected ? '#1c1c1c' : 'transparent',
display: 'flex', alignItems: 'center', gap: '12px',
}}
className="card-row-header"
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '13px', color: '#d8d8d8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{set.set_name}
</div>
<div style={{ fontSize: '11px', color: '#444', marginTop: '2px' }}>
{set.set_code}{set.tcg_date ? ` · ${set.tcg_date.slice(0, 4)}` : ''}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: '12px', color: set.owned_count > 0 ? '#aaa' : '#333' }}>
{set.owned_count > 0 ? `${set.owned_count}/${set.total_in_db}` : `${set.total_in_db}`}
</div>
{set.owned_count > 0 && (
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>{pct}%</div>
)}
</div>
</div>
);
}
function SetCardRow({ card, zebra }) {
const { ownedAmounts, updateAmount } = useContext(CardContext);
const key = `${card.set_id ?? ''}-${card.rarity_id}`;
const current = ownedAmounts[card.id]?.[key] ?? card.amount_owned ?? 0;
const save = async (next) => {
try {
await updateCardAmount(card.id, card.set_id, card.rarity_id, next);
updateAmount(card.id, key, next);
} catch (err) {
console.error('Failed to update amount', err);
}
};
return (
<div style={{
display: 'grid', gridTemplateColumns: '1fr 120px 90px',
gap: '8px', padding: '8px 16px', alignItems: 'center',
background: zebra ? '#1e1e1e' : '#161616', borderBottom: '1px solid #1a1a1a',
}}>
<span style={{ fontSize: '13px', color: '#d8d8d8' }}>{card.name}</span>
<span style={{ fontSize: '12px', color: '#777' }}>{card.rarity_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', justifyContent: 'flex-end' }}>
<button className="icon-btn" onClick={() => current > 0 && save(current - 1)} disabled={current === 0}></button>
<span style={{ minWidth: '20px', textAlign: 'center', color: '#e0e0e0', fontSize: '13px' }}>{current}</span>
<button className="icon-btn" onClick={() => save(current + 1)}>+</button>
</div>
</div>
);
}
function SetsPage() {
const [sets, setSets] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedSet, setSelectedSet] = useState(null);
const [setCards, setSetCards] = useState([]);
const [cardsLoading, setCardsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => {
fetchSets().then(setSets).catch(console.error).finally(() => setLoading(false));
}, []);
const selectSet = (set) => {
if (selectedSet?.id === set.id) { setSelectedSet(null); setSetCards([]); return; }
setSelectedSet(set);
setSetCards([]);
setCardsLoading(true);
fetchSetCards(set.id).then(setSetCards).catch(console.error).finally(() => setCardsLoading(false));
};
const filteredSets = useMemo(() => {
if (!searchTerm) return sets;
return sets.filter(s => fuzzyMatch(s.set_name, searchTerm) || fuzzyMatch(s.set_code, searchTerm));
}, [sets, searchTerm]);
const setDetail = selectedSet && (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#e0e0e0' }}>{selectedSet.set_name}</div>
<div style={{ fontSize: '11px', color: '#555', marginTop: '2px' }}>
{selectedSet.set_code}{selectedSet.tcg_date ? ` · ${selectedSet.tcg_date.slice(0, 4)}` : ''} · {selectedSet.total_in_db} cards
{selectedSet.owned_count > 0 && ` · ${selectedSet.owned_count} owned`}
</div>
</div>
{cardsLoading
? <p style={{ padding: '1rem', color: '#555', fontSize: '13px' }}>Loading</p>
: (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 120px 90px',
gap: '8px', padding: '5px 16px',
fontSize: '10px', color: '#444', textTransform: 'uppercase', letterSpacing: '0.05em',
borderBottom: '1px solid #222',
}}>
<span>Card</span><span>Rarity</span><span style={{ textAlign: 'right' }}>Owned</span>
</div>
{setCards.map((card, i) => (
<SetCardRow key={`${card.id}-${card.rarity_id}`} card={card} zebra={i % 2 === 1} />
))}
</div>
)
}
</div>
);
if (loading) return <p style={{ padding: '1rem', color: '#666' }}>Loading sets</p>;
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sets list */}
<div style={{ width: isMobile ? '100%' : '340px', display: 'flex', flexDirection: 'column', borderRight: isMobile ? 'none' : '1px solid #2a2a2a', overflow: 'hidden', flexShrink: 0 }}>
<div style={{ padding: '10px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
<SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
</div>
<div style={{ padding: '4px 16px', fontSize: '11px', color: '#444', flexShrink: 0, borderBottom: '1px solid #1a1a1a' }}>
{filteredSets.length.toLocaleString()} sets
</div>
<Virtuoso
style={{ flex: 1 }}
data={filteredSets}
itemContent={(_, set) => (
<SetRow set={set} isSelected={selectedSet?.id === set.id} onClick={() => selectSet(set)} />
)}
/>
</div>
{/* Desktop: set detail */}
{!isMobile && (
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{setDetail ?? <span style={{ color: '#333', fontSize: '13px', margin: '2rem auto' }}>Select a set</span>}
</div>
)}
{/* Mobile: bottom sheet */}
{isMobile && selectedSet && (
<>
<div className="sheet-backdrop" onClick={() => { setSelectedSet(null); setSetCards([]); }} />
<div className="bottom-sheet">
<div className="sheet-handle-area"><div className="sheet-handle" /></div>
{setDetail}
</div>
</>
)}
</div>
);
}
export default SetsPage;
+12 -1
View File
@@ -1,4 +1,3 @@
//api.jsx
const API_BASE = '/api';
export async function fetchCards() {
@@ -44,4 +43,16 @@ export async function triggerFullImport() {
});
if (!response.ok) throw new Error('Failed to trigger full import');
return await response.json();
}
export async function fetchSets() {
const response = await fetch(`${API_BASE}/sets`);
if (!response.ok) throw new Error('Failed to fetch sets');
return await response.json();
}
export async function fetchSetCards(setId) {
const response = await fetch(`${API_BASE}/sets/${setId}/cards`);
if (!response.ok) throw new Error('Failed to fetch set cards');
return await response.json();
}