Add filters, stats bar, and fix bugs
ci/woodpecker/push/woodpecker Pipeline was successful

- FilterBar: type chips (Monster/Spell/Trap), owned-only toggle, sort by name/most-owned
- Stats bar: cards in DB, unique owned, total copies
- Card detail panel: type, race, attribute, level/link stars from existing data
- useMemo for filtered+sorted card list (was re-sorting every render)
- Footer: refresh card list after successful full import
- PrintingRow: remove broken custom memo comparator (was comparing static DB field)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 21:06:12 +02:00
parent ebf83aa503
commit a0240499e8
4 changed files with 158 additions and 61 deletions
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
const TYPES = ['All', 'Monster', 'Spell', 'Trap'];
function FilterBar({ typeFilter, setTypeFilter, ownedOnly, setOwnedOnly, sortBy, setSortBy }) {
return (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', margin: '0.5rem 0' }}>
<div style={{ display: 'flex', gap: '0.25rem' }}>
{TYPES.map(t => (
<button
key={t}
onClick={() => setTypeFilter(t)}
style={{
padding: '2px 10px',
borderRadius: '12px',
border: '1px solid #ccc',
background: typeFilter === t ? '#444' : 'transparent',
color: typeFilter === t ? '#fff' : '#666',
cursor: 'pointer',
fontSize: '0.8rem',
}}
>
{t}
</button>
))}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.85rem', color: '#555' }}>
<input type="checkbox" checked={ownedOnly} onChange={e => setOwnedOnly(e.target.checked)} />
Owned only
</label>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
style={{ fontSize: '0.8rem', padding: '2px 4px', border: '1px solid #ccc', borderRadius: '4px', color: '#555' }}
>
<option value="name">Sort: A Z</option>
<option value="owned">Sort: Most owned</option>
</select>
</div>
);
}
export default FilterBar;
+2 -1
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
import { fetchDatabaseVersion, triggerFullImport } from '../../services/api'; import { fetchDatabaseVersion, triggerFullImport } from '../../services/api';
import './Footer.css'; import './Footer.css';
function Footer() { function Footer({ onImportComplete }) {
const [dbVersion, setDbVersion] = useState(null); const [dbVersion, setDbVersion] = useState(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [modalMessage, setModalMessage] = useState(''); const [modalMessage, setModalMessage] = useState('');
@@ -28,6 +28,7 @@ function Footer() {
setDbVersion(data.database_version); setDbVersion(data.database_version);
setModalMessage(result.message || 'Import completed'); setModalMessage(result.message || 'Import completed');
setShowModal(true); setShowModal(true);
if (onImportComplete) await onImportComplete();
} catch (err) { } catch (err) {
setModalMessage(`Import failed: ${err.message}`); setModalMessage(`Import failed: ${err.message}`);
setShowModal(true); setShowModal(true);
+1 -15
View File
@@ -59,18 +59,4 @@ function PrintingRow({ card_id, printing }) {
); );
} }
export default React.memo( export default React.memo(PrintingRow);
PrintingRow,
(prevProps, nextProps) => {
const prevKey = `${prevProps.printing.set_id}-${prevProps.printing.rarity_id}`;
const nextKey = `${nextProps.printing.set_id}-${nextProps.printing.rarity_id}`;
const prevAmount = prevProps.printing.amount_owned;
const nextAmount = nextProps.printing.amount_owned;
return (
prevProps.card_id === nextProps.card_id &&
prevKey === nextKey &&
prevAmount === nextAmount
);
}
);
+103 -38
View File
@@ -1,21 +1,18 @@
//HomePage.jsx import React, { useEffect, useState, useContext, useMemo, useCallback } from 'react';
import React, { useEffect, useState, useContext } from 'react';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import CardRow from '../components/CardRow/CardRow'; import CardRow from '../components/CardRow/CardRow';
import SearchBar from '../components/SearchBar/SearchBar'; import SearchBar from '../components/SearchBar/SearchBar';
import FilterBar from '../components/FilterBar/FilterBar';
import { CardContext } from '../store/CardContext'; import { CardContext } from '../store/CardContext';
import { fetchCards, fetchCardImage } from '../services/api'; import { fetchCards, fetchCardImage } from '../services/api';
import Footer from '../components/Footer/Footer'; import Footer from '../components/Footer/Footer';
// Debounce hook
function useDebouncedValue(value, delay = 250) { function useDebouncedValue(value, delay = 250) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay); const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [value, delay]); }, [value, delay]);
return debounced; return debounced;
} }
@@ -24,78 +21,146 @@ function HomePage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { expandedCardId, cardImages, setCardImage } = useContext(CardContext); const [typeFilter, setTypeFilter] = useState('All');
const [ownedOnly, setOwnedOnly] = useState(false);
const [sortBy, setSortBy] = useState('name');
const { expandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 250); const debouncedSearchTerm = useDebouncedValue(searchTerm, 250);
useEffect(() => { const loadCards = useCallback(() => {
fetchCards() return fetchCards().then(data => setCards(data));
.then(data => setCards(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []); }, []);
// Load image for the currently expanded card useEffect(() => {
loadCards()
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [loadCards]);
useEffect(() => { useEffect(() => {
if (!expandedCardId || cardImages[expandedCardId]) return; if (!expandedCardId || cardImages[expandedCardId]) return;
fetchCardImage(expandedCardId) fetchCardImage(expandedCardId)
.then(image => setCardImage(expandedCardId, image)) .then(image => setCardImage(expandedCardId, image))
.catch(err => console.error('Failed to load card image', err)); .catch(err => console.error('Failed to load card image', err));
}, [expandedCardId, cardImages, setCardImage]); }, [expandedCardId, cardImages, setCardImage]);
if (loading) return <p>Loading cards...</p>; const getCardTotal = useCallback((card) =>
if (error) return <p>Error: {error}</p>; card.printings?.reduce((sum, p) => {
const key = `${p.set_id}-${p.rarity_id}`;
return sum + (ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0);
}, 0) ?? 0
, [ownedAmounts]);
const stats = useMemo(() => ({
totalCards: cards.length,
ownedUnique: cards.filter(c => getCardTotal(c) > 0).length,
totalCopies: cards.reduce((sum, c) => sum + getCardTotal(c), 0),
}), [cards, getCardTotal]);
const filteredCards = useMemo(() => {
let result = cards;
if (debouncedSearchTerm) {
const lower = debouncedSearchTerm.toLowerCase();
result = result.filter(c => c.name.toLowerCase().includes(lower));
}
if (typeFilter !== 'All') {
const lower = typeFilter.toLowerCase();
result = result.filter(c => c.type.toLowerCase().includes(lower));
}
if (ownedOnly) {
result = result.filter(c => getCardTotal(c) > 0);
}
const sorted = [...result];
if (sortBy === 'owned') {
sorted.sort((a, b) => getCardTotal(b) - getCardTotal(a));
} else {
sorted.sort((a, b) => a.name.localeCompare(b.name));
}
return sorted;
}, [cards, debouncedSearchTerm, typeFilter, ownedOnly, sortBy, getCardTotal]);
if (loading) return <p style={{ padding: '1rem' }}>Loading cards...</p>;
if (error) return <p style={{ padding: '1rem', color: 'red' }}>Error: {error}</p>;
const expandedCard = cards.find(c => c.id === expandedCardId); const expandedCard = cards.find(c => c.id === expandedCardId);
// Filter + sort using debounced search
const filteredCards = cards
.filter(card =>
card.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name));
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Main content row */} {/* Stats bar */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <div style={{
{/* Left panel: card list */} display: 'flex', gap: '2rem', padding: '0.4rem 1rem',
<div style={{ flex: 2, borderRight: '1px solid #ccc', padding: '1rem', overflow: 'hidden' }}> background: '#f5f5f5', borderBottom: '1px solid #ddd',
<h2>Card List</h2> fontSize: '0.85rem', color: '#555', flexShrink: 0,
<SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} /> }}>
<span><strong>{stats.totalCards.toLocaleString()}</strong> cards in DB</span>
<span><strong>{stats.ownedUnique.toLocaleString()}</strong> unique owned</span>
<span><strong>{stats.totalCopies.toLocaleString()}</strong> total copies</span>
</div>
{/* ✅ Virtualized list */} {/* Main content */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left panel */}
<div style={{ flex: 2, borderRight: '1px solid #ccc', padding: '1rem', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
<FilterBar
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
ownedOnly={ownedOnly} setOwnedOnly={setOwnedOnly}
sortBy={sortBy} setSortBy={setSortBy}
/>
<div style={{ fontSize: '0.8rem', color: '#999', marginBottom: '0.25rem' }}>
{filteredCards.length.toLocaleString()} cards
</div>
<Virtuoso <Virtuoso
style={{ height: 'calc(100vh - 100px)' }} style={{ flex: 1 }}
data={filteredCards} data={filteredCards}
itemContent={(index, card) => <CardRow key={card.id} card={card} />} itemContent={(index, card) => <CardRow key={card.id} card={card} />}
/> />
</div> </div>
{/* Right panel: card image */} {/* Right panel */}
<div style={{ flex: 1, padding: '1rem', overflowY: 'auto' }}> <div style={{ flex: 1, padding: '1rem', overflowY: 'auto' }}>
<h2>Card Image / Details</h2>
{expandedCardId && expandedCard ? ( {expandedCardId && expandedCard ? (
cardImages[expandedCardId] ? ( <>
{cardImages[expandedCardId] ? (
<img <img
src={cardImages[expandedCardId]} src={cardImages[expandedCardId]}
alt={expandedCard.name} alt={expandedCard.name}
style={{ maxWidth: '100%', maxHeight: '80vh' }} style={{ maxWidth: '100%' }}
/> />
) : ( ) : (
<p>Loading image...</p> <p>Loading image...</p>
) )}
<h3 style={{ margin: '0.75rem 0 0.2rem' }}>{expandedCard.name}</h3>
<p style={{ margin: '0.2rem 0', color: '#888', fontSize: '0.85rem' }}>
{expandedCard.type}
{expandedCard.race && ` · ${expandedCard.race}`}
{expandedCard.attribute && ` · ${expandedCard.attribute}`}
</p>
{expandedCard.level != null && (
<p style={{ margin: '0.2rem 0', fontSize: '0.85rem' }}>
{'★'.repeat(Math.min(expandedCard.level, 13))} Lv.{expandedCard.level}
</p>
)}
{expandedCard.link_val != null && (
<p style={{ margin: '0.2rem 0', fontSize: '0.85rem' }}>
Link {expandedCard.link_val}
</p>
)}
</>
) : ( ) : (
<p>Click a card to see its image</p> <p style={{ color: '#aaa' }}>Click a card to see details</p>
)} )}
</div> </div>
</div> </div>
{/* Footer: bottom right */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Footer /> <Footer onImportComplete={loadCards} />
</div> </div>
</div> </div>
); );