diff --git a/src/components/FilterBar/FilterBar.jsx b/src/components/FilterBar/FilterBar.jsx new file mode 100644 index 0000000..195e3bb --- /dev/null +++ b/src/components/FilterBar/FilterBar.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + +const TYPES = ['All', 'Monster', 'Spell', 'Trap']; + +function FilterBar({ typeFilter, setTypeFilter, ownedOnly, setOwnedOnly, sortBy, setSortBy }) { + return ( +
+
+ {TYPES.map(t => ( + + ))} +
+ + + + +
+ ); +} + +export default FilterBar; diff --git a/src/components/Footer/Footer.jsx b/src/components/Footer/Footer.jsx index 94ada3c..4c9eec7 100644 --- a/src/components/Footer/Footer.jsx +++ b/src/components/Footer/Footer.jsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { fetchDatabaseVersion, triggerFullImport } from '../../services/api'; import './Footer.css'; -function Footer() { +function Footer({ onImportComplete }) { const [dbVersion, setDbVersion] = useState(null); const [importing, setImporting] = useState(false); const [modalMessage, setModalMessage] = useState(''); @@ -28,6 +28,7 @@ function Footer() { setDbVersion(data.database_version); setModalMessage(result.message || 'Import completed'); setShowModal(true); + if (onImportComplete) await onImportComplete(); } catch (err) { setModalMessage(`Import failed: ${err.message}`); setShowModal(true); diff --git a/src/components/PrintingRow/PrintingRow.jsx b/src/components/PrintingRow/PrintingRow.jsx index a81648c..f78e418 100644 --- a/src/components/PrintingRow/PrintingRow.jsx +++ b/src/components/PrintingRow/PrintingRow.jsx @@ -59,18 +59,4 @@ function PrintingRow({ card_id, printing }) { ); } -export default React.memo( - 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 - ); - } -); \ No newline at end of file +export default React.memo(PrintingRow); \ No newline at end of file diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 62183d7..8768590 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,21 +1,18 @@ -//HomePage.jsx -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, useMemo, useCallback } from 'react'; import { Virtuoso } from 'react-virtuoso'; import CardRow from '../components/CardRow/CardRow'; import SearchBar from '../components/SearchBar/SearchBar'; +import FilterBar from '../components/FilterBar/FilterBar'; import { CardContext } from '../store/CardContext'; import { fetchCards, fetchCardImage } from '../services/api'; import Footer from '../components/Footer/Footer'; -// Debounce hook function useDebouncedValue(value, delay = 250) { const [debounced, setDebounced] = useState(value); - useEffect(() => { const handler = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(handler); }, [value, delay]); - return debounced; } @@ -24,81 +21,149 @@ function HomePage() { const [searchTerm, setSearchTerm] = useState(''); const [loading, setLoading] = useState(true); 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); - useEffect(() => { - fetchCards() - .then(data => setCards(data)) - .catch(err => setError(err.message)) - .finally(() => setLoading(false)); + const loadCards = useCallback(() => { + return fetchCards().then(data => setCards(data)); }, []); - // Load image for the currently expanded card + useEffect(() => { + loadCards() + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [loadCards]); useEffect(() => { if (!expandedCardId || cardImages[expandedCardId]) return; - fetchCardImage(expandedCardId) .then(image => setCardImage(expandedCardId, image)) .catch(err => console.error('Failed to load card image', err)); }, [expandedCardId, cardImages, setCardImage]); - if (loading) return

Loading cards...

; - if (error) return

Error: {error}

; + const getCardTotal = useCallback((card) => + 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

Loading cards...

; + if (error) return

Error: {error}

; 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 (
- {/* Main content row */} -
- {/* Left panel: card list */} -
-

Card List

- + {/* Stats bar */} +
+ {stats.totalCards.toLocaleString()} cards in DB + {stats.ownedUnique.toLocaleString()} unique owned + {stats.totalCopies.toLocaleString()} total copies +
- {/* ✅ Virtualized list */} + {/* Main content */} +
+ {/* Left panel */} +
+ + +
+ {filteredCards.length.toLocaleString()} cards +
} />
- {/* Right panel: card image */} + {/* Right panel */}
-

Card Image / Details

{expandedCardId && expandedCard ? ( - cardImages[expandedCardId] ? ( - {expandedCard.name} - ) : ( -

Loading image...

- ) + <> + {cardImages[expandedCardId] ? ( + {expandedCard.name} + ) : ( +

Loading image...

+ )} +

{expandedCard.name}

+

+ {expandedCard.type} + {expandedCard.race && ` · ${expandedCard.race}`} + {expandedCard.attribute && ` · ${expandedCard.attribute}`} +

+ {expandedCard.level != null && ( +

+ {'★'.repeat(Math.min(expandedCard.level, 13))} Lv.{expandedCard.level} +

+ )} + {expandedCard.link_val != null && ( +

+ Link {expandedCard.link_val} +

+ )} + ) : ( -

Click a card to see its image

+

Click a card to see details

)}
- {/* Footer: bottom right */}
-
); } -export default HomePage; \ No newline at end of file +export default HomePage;