From df651135645694bae442e28e9ea9e18becc31533 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 12:07:38 -0500 Subject: [PATCH] Extract AuthenticatedCards into hook + view (cards Brief 3). Moves card-browser state and handlers to lib/use-cards-page.js and markup to components/CardsPageView.js, leaving a thin ProtectedRoute composer in pages/cards.js. Co-authored-by: Cursor --- components/CardsPageView.js | 403 ++++++++++++++++ lib/use-cards-page.js | 578 +++++++++++++++++++++++ pages/cards.js | 891 +----------------------------------- 3 files changed, 988 insertions(+), 884 deletions(-) create mode 100644 components/CardsPageView.js create mode 100644 lib/use-cards-page.js diff --git a/components/CardsPageView.js b/components/CardsPageView.js new file mode 100644 index 0000000..20d4276 --- /dev/null +++ b/components/CardsPageView.js @@ -0,0 +1,403 @@ +import CardItem from './CardItem'; +import BulkSelectionToolbar from './BulkSelectionToolbar'; +import CollectionSelectionModal from './CollectionSelectionModal'; + +export default function CardsPageView(props) { + const { + cards, + loadingMore, + searchQuery, + selectedTCG, + setSelectedTCG, + selectedRarity, + setSelectedRarity, + selectedSet, + setSelectedSet, + selectedValueRange, + setSelectedValueRange, + viewMode, + setViewMode, + hasMore, + selectedCards, + favoritedCards, + showCollectionModal, + setShowCollectionModal, + cardsToAdd, + handleSearchChange, + fetchCards, + loadMoreCards, + hasMoreRef, + setPagination, + setCards, + setHasMore, + tcgOptions, + rarityOptions, + setOptions, + valueRangeOptions, + handleToggleSelect, + handleClearSelection, + handleToggleFavorite, + handleBulkAddToCollection, + handleBulkAddToDeck, + handleBulkMarkAsOwned, + handleBulkRemoveFromOwned, + handleBulkFavorite, + handleBulkDelete, + handleAddToCollections, + } = props; + + return ( + <> + {/* Header */} +
+
+
+

+ Cards +

+

+ Browse and manage your card collection +

+
+
+ + +
+
+
+ + {/* Quick Filters */} +
+
+

+ Quick Filters +

+
+ + {tcgOptions.map(option => ( + + ))} +
+
+ + {/* Advanced Filters */} +
+
+ +
+ handleSearchChange(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + setHasMore(true); + hasMoreRef.current = true; + fetchCards(false); + } + }} + /> + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Active Filters Display */} + {(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && ( +
+
+ {selectedTCG !== 'all' && ( + + {tcgOptions.find(opt => opt.value === selectedTCG)?.label} + + + )} + {selectedRarity !== 'all' && ( + + {rarityOptions.find(opt => opt.value === selectedRarity)?.label} + + + )} + {selectedSet !== 'all' && ( + + {setOptions.find(opt => opt.value === selectedSet)?.label} + + + )} + {selectedValueRange !== 'all' && ( + + {valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label} + + + )} + +
+
+ )} +
+ + {/* Cards Display */} +
+ {cards.length === 0 ? ( +
+
🃏
+

+ No cards found +

+

+ Try adjusting your search or filters +

+
+ ) : ( + <> +
+ {cards.map((card) => ( + c.id === card.id)} + onToggleSelect={handleToggleSelect} + onAddToCollection={(card) => handleBulkAddToCollection([card])} + onAddToDeck={handleBulkAddToDeck} + onToggleFavorite={handleToggleFavorite} + isFavorited={favoritedCards.has(card.id)} + /> + ))} +
+ + {/* Infinite Scroll Loading Indicator */} + {loadingMore && ( +
+
+
+ Loading more cards... +
+
+ )} + + {/* Infinite Scroll Sentinel */} +
+ + {/* Load More Button */} + {hasMore && !loadingMore && ( +
+ +
+ )} + + {/* End of results indicator */} + {!hasMore && cards.length > 0 && ( +
+ No more cards to load +
+ )} + + + + )} +
+ + {/* Bulk Selection Toolbar */} + + + {/* Collection Selection Modal */} + setShowCollectionModal(false)} + cards={cardsToAdd} + onAddToCollections={handleAddToCollections} + /> + + ); +} diff --git a/lib/use-cards-page.js b/lib/use-cards-page.js new file mode 100644 index 0000000..3192ae6 --- /dev/null +++ b/lib/use-cards-page.js @@ -0,0 +1,578 @@ +import { useState, useEffect, useRef } from 'react'; +import { VOCAB } from './collection-vocabulary.js'; + +/** + * Card browser state: search/filters, infinite scroll, bulk selection, favorites. + */ +export function useCardsPage() { + + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTCG, setSelectedTCG] = useState('all'); + const [selectedRarity, setSelectedRarity] = useState('all'); + const [selectedSet, setSelectedSet] = useState('all'); + const [selectedValueRange, setSelectedValueRange] = useState('all'); + const [viewMode, setViewMode] = useState('grid'); // grid or list + const [pagination, setPagination] = useState({ + page: 1, + limit: 50, + total: 0, + pages: 0 + }); + const [filters, setFilters] = useState({ + games: [], + rarities: [], + sets: [] + }); + const [hasMore, setHasMore] = useState(true); + const loadingMoreRef = useRef(false); + const hasMoreRef = useRef(true); + + // Bulk selection state + const [selectedCards, setSelectedCards] = useState([]); + const [favoritedCards, setFavoritedCards] = useState(new Set()); + + // Modal states + const [showCollectionModal, setShowCollectionModal] = useState(false); + const [cardsToAdd, setCardsToAdd] = useState([]); + + // Mana symbol settings + const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); + + // Fetch cards from database + const fetchCards = async (isLoadMore = false) => { + try { + if (isLoadMore) { + setLoadingMore(true); + loadingMoreRef.current = true; + } else { + setLoading(true); + } + + const params = new URLSearchParams({ + query: searchQuery, + game: selectedTCG, + rarity: selectedRarity, + set: selectedSet, + minPrice: selectedValueRange === 'under-50' ? '0' : + selectedValueRange === '50-100' ? '50' : + selectedValueRange === '100-500' ? '100' : + selectedValueRange === '500-1000' ? '500' : + selectedValueRange === 'over-1000' ? '1000' : '', + maxPrice: selectedValueRange === '50-100' ? '100' : + selectedValueRange === '100-500' ? '500' : + selectedValueRange === '500-1000' ? '1000' : '', + page: pagination.page.toString(), + limit: pagination.limit.toString() + }); + + const response = await fetch(`/api/cards/search?${params}`); + const data = await response.json(); + + if (data.success) { + + + if (isLoadMore) { + setCards(prevCards => [...prevCards, ...data.cards]); + } else { + setCards(data.cards); + } + setPagination(data.pagination); + setFilters(data.filters); + const hasMoreCards = data.pagination.page < data.pagination.pages; + setHasMore(hasMoreCards); + hasMoreRef.current = hasMoreCards; + } else { + console.error('Failed to fetch cards:', data.error); + if (!isLoadMore) { + setCards([]); + } + } + } catch (error) { + console.error('Error fetching cards:', error); + if (!isLoadMore) { + setCards([]); + } + } finally { + setLoading(false); + setLoadingMore(false); + loadingMoreRef.current = false; + } + }; + + // Load user's favorited cards + const loadFavoritedCards = async () => { + try { + const token = localStorage.getItem('auth_token'); + if (!token) return; + + const response = await fetch('/api/favorites?type=card', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id))); + setFavoritedCards(favoriteIds); + } + } catch (error) { + console.error('Error loading favorited cards:', error); + } + }; + + // Initial load + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- reset list state when filters change, then fetch */ + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + setHasMore(true); + hasMoreRef.current = true; + fetchCards(false); + loadFavoritedCards(); // Load user's favorites + /* eslint-enable react-hooks/set-state-in-effect */ + // eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state + }, [selectedTCG, selectedRarity, selectedSet, selectedValueRange]); + + // Handle search with debounce + const [searchTimeout, setSearchTimeout] = useState(null); + + const handleSearchChange = (value) => { + setSearchQuery(value); + + // Clear existing timeout + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + // Set new timeout for search + const newTimeout = setTimeout(() => { + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + setHasMore(true); + hasMoreRef.current = true; + fetchCards(false); + }, 500); // 500ms delay + + setSearchTimeout(newTimeout); + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + }; + }, [searchTimeout]); + + // Load more cards + const loadMoreCards = async () => { + + + if (!loadingMoreRef.current && hasMoreRef.current) { + const nextPage = pagination.page + 1; + + setPagination(prev => ({ ...prev, page: nextPage })); + + // Use the next page number directly in the fetch + try { + setLoadingMore(true); + loadingMoreRef.current = true; + + const params = new URLSearchParams({ + query: searchQuery, + game: selectedTCG, + rarity: selectedRarity, + set: selectedSet, + minPrice: selectedValueRange === 'under-50' ? '0' : + selectedValueRange === '50-100' ? '50' : + selectedValueRange === '100-500' ? '100' : + selectedValueRange === '500-1000' ? '500' : + selectedValueRange === 'over-1000' ? '1000' : '', + maxPrice: selectedValueRange === '50-100' ? '100' : + selectedValueRange === '100-500' ? '500' : + selectedValueRange === '500-1000' ? '1000' : '', + page: nextPage.toString(), + limit: pagination.limit.toString() + }); + + const response = await fetch(`/api/cards/search?${params}`); + const data = await response.json(); + + if (data.success) { + + setCards(prevCards => [...prevCards, ...data.cards]); + setPagination(data.pagination); + setFilters(data.filters); + const hasMoreCards = data.pagination.page < data.pagination.pages; + setHasMore(hasMoreCards); + hasMoreRef.current = hasMoreCards; + } else { + console.error('Failed to fetch more cards:', data.error); + } + } catch (error) { + console.error('Error fetching more cards:', error); + } finally { + setLoadingMore(false); + loadingMoreRef.current = false; + } + } + }; + + // Update refs when state changes + useEffect(() => { + hasMoreRef.current = hasMore; + loadingMoreRef.current = loadingMore; + }, [hasMore, loadingMore]); + + // Intersection Observer for infinite scroll + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) { + loadMoreCards(); + } + }); + }, + { + rootMargin: '50px', + threshold: 0.1 + } + ); + + const setupObserver = () => { + const sentinel = document.getElementById('infinite-scroll-sentinel'); + if (sentinel) { + observer.observe(sentinel); + } else { + setTimeout(setupObserver, 100); + } + }; + + setupObserver(); + + return () => { + const sentinel = document.getElementById('infinite-scroll-sentinel'); + if (sentinel) { + observer.unobserve(sentinel); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- observer setup once; loadMoreCards uses refs for latest state + }, []); + + const getRarityColor = (rarity) => { + // Map database rarity values to colors + const rarityColorMap = { + 'common': '#6B7280', + 'uncommon': '#10B981', + 'rare': '#F59E0B', + 'mythic': '#FFD700', + 'holographic': '#FF6B6B', + 'enchanted': '#A855F7', + 'secret rare': '#FF6B6B', + 'ultra rare': '#A855F7' + }; + return rarityColorMap[rarity?.toLowerCase()] || '#6B7280'; + }; + + const tcgOptions = [ + { value: 'MTG', label: 'Magic: The Gathering', color: 'purple', icon: '🔮' }, + { value: 'Pokemon', label: 'Pokemon', color: 'blue', icon: '⚡' }, + { value: 'Lorcana', label: 'Disney Lorcana', color: 'pink', icon: '✨' } + ]; + + const rarityOptions = [ + { value: 'all', label: 'All Rarities' }, + { value: 'common', label: 'Common' }, + { value: 'uncommon', label: 'Uncommon' }, + { value: 'rare', label: 'Rare' }, + { value: 'mythic', label: 'Mythic' }, + { value: 'enchanted', label: 'Enchanted' }, + ...filters.rarities.filter(rarity => + !['all', 'common', 'uncommon', 'rare', 'mythic', 'enchanted'].includes(rarity) + ).map(rarity => ({ + value: rarity, + label: rarity, + color: getRarityColor(rarity) + })) + ]; + + const setOptions = [ + { value: 'all', label: 'All Sets' }, + ...filters.sets.map(set => ({ + value: set, + label: set + })) + ]; + + const valueRangeOptions = [ + { value: 'all', label: 'All Values' }, + { value: 'under-50', label: 'Under $50' }, + { value: '50-100', label: '$50 - $100' }, + { value: '100-500', label: '$100 - $500' }, + { value: '500-1000', label: '$500 - $1,000' }, + { value: 'over-1000', label: 'Over $1,000' } + ]; + + + + const formatCurrency = (amount) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount); + }; + + const getRarityLabel = (rarity) => { + const rarityOption = rarityOptions.find(option => option.value === rarity); + return rarityOption ? rarityOption.label : rarity; + }; + + // Bulk selection handlers + const handleToggleSelect = (card) => { + setSelectedCards(prev => { + const isSelected = prev.some(c => c.id === card.id); + if (isSelected) { + return prev.filter(c => c.id !== card.id); + } else { + return [...prev, card]; + } + }); + }; + + const handleClearSelection = () => { + setSelectedCards([]); + }; + + const handleToggleFavorite = async (card) => { + try { + const isFavorited = favoritedCards.has(card.id); + const method = isFavorited ? 'DELETE' : 'POST'; + + const response = await fetch('/api/favorites', { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + }, + body: JSON.stringify({ + itemType: 'card', + itemId: card.id + }) + }); + + if (response.ok) { + setFavoritedCards(prev => { + const newSet = new Set(prev); + if (isFavorited) { + newSet.delete(card.id); + } else { + newSet.add(card.id); + } + return newSet; + }); + } + } catch (error) { + console.error('Error toggling favorite:', error); + } + }; + + // Bulk action handlers + const handleBulkAddToCollection = (cards) => { + setCardsToAdd(cards); + setShowCollectionModal(true); + }; + + const handleBulkAddToDeck = (cards) => { + console.log('Adding to deck:', cards); + alert(`Adding ${cards.length} cards to deck (functionality coming soon)`); + }; + + const handleBulkMarkAsOwned = async (cards) => { + try { + const token = localStorage.getItem('auth_token'); + if (!token) { + alert(`Please log in to ${VOCAB.ADD_TO_MY_COLLECTION.toLowerCase()}`); + return; + } + + let successCount = 0; + for (const card of cards) { + try { + const response = await fetch(`/api/cards/${card.id}/ownership`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ quantity: 1 }) + }); + + if (response.ok) { + successCount++; + } + } catch (error) { + console.error(`Error marking card ${card.id} as owned:`, error); + } + } + + if (successCount === cards.length) { + alert(`Successfully added ${cards.length} cards to ${VOCAB.MY_COLLECTION}!`); + } else { + alert(`Added ${successCount} out of ${cards.length} cards to ${VOCAB.MY_COLLECTION}. Some operations may have failed.`); + } + + // Clear selection after successful operation + setSelectedCards([]); + } catch (error) { + console.error('Error in bulk mark as owned:', error); + alert(`Failed to add cards to ${VOCAB.MY_COLLECTION}. Please try again.`); + } + }; + + const handleBulkRemoveFromOwned = async (cards) => { + try { + const token = localStorage.getItem('auth_token'); + if (!token) { + alert(`Please log in to ${VOCAB.REMOVE_FROM_MY_COLLECTION.toLowerCase()}`); + return; + } + + let successCount = 0; + for (const card of cards) { + try { + const response = await fetch(`/api/cards/${card.id}/ownership`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ quantity: 0 }) + }); + + if (response.ok) { + successCount++; + } + } catch (error) { + console.error(`Error removing card ${card.id} from owned:`, error); + } + } + + if (successCount === cards.length) { + alert(`Successfully removed ${cards.length} cards from ${VOCAB.MY_COLLECTION}!`); + } else { + alert(`Removed ${successCount} out of ${cards.length} cards from ${VOCAB.MY_COLLECTION}. Some operations may have failed.`); + } + + // Clear selection after successful operation + setSelectedCards([]); + } catch (error) { + console.error('Error in bulk remove from owned:', error); + alert(`Failed to remove cards from ${VOCAB.MY_COLLECTION}. Please try again.`); + } + }; + + const handleBulkFavorite = async (cards) => { + try { + for (const card of cards) { + if (!favoritedCards.has(card.id)) { + await fetch('/api/favorites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + }, + body: JSON.stringify({ + itemType: 'card', + itemId: card.id + }) + }); + } + } + + setFavoritedCards(prev => { + const newSet = new Set(prev); + cards.forEach(card => newSet.add(card.id)); + return newSet; + }); + + alert(`Added ${cards.length} cards to favorites`); + } catch (error) { + console.error('Error bulk favoriting:', error); + } + }; + + const handleBulkDelete = (cards) => { + console.log('Bulk delete:', cards); + alert(`Bulk delete functionality coming soon for ${cards.length} cards`); + }; + + // Collection modal handlers + const handleAddToCollections = (results, selectedCollectionIds, cards) => { + const successCount = results.filter(r => r.success).length; + const totalAttempts = results.length; + + if (successCount === totalAttempts) { + alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} list${selectedCollectionIds.length !== 1 ? 's' : ''}!`); + } else { + alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`); + } + + // Clear selection after successful addition + setSelectedCards([]); + }; + + const showInitialLoading = loading && cards.length === 0; + + return { + cards, + loading, + loadingMore, + showInitialLoading, + searchQuery, + selectedTCG, + setSelectedTCG, + selectedRarity, + setSelectedRarity, + selectedSet, + setSelectedSet, + selectedValueRange, + setSelectedValueRange, + viewMode, + setViewMode, + filters, + hasMore, + selectedCards, + favoritedCards, + showCollectionModal, + setShowCollectionModal, + cardsToAdd, + handleSearchChange, + fetchCards, + loadMoreCards, + hasMoreRef, + setPagination, + setCards, + setHasMore, + tcgOptions, + rarityOptions, + setOptions, + valueRangeOptions, + handleToggleSelect, + handleClearSelection, + handleToggleFavorite, + handleBulkAddToCollection, + handleBulkAddToDeck, + handleBulkMarkAsOwned, + handleBulkRemoveFromOwned, + handleBulkFavorite, + handleBulkDelete, + handleAddToCollections, + }; +} + diff --git a/pages/cards.js b/pages/cards.js index 27f5ef6..7b86e77 100644 --- a/pages/cards.js +++ b/pages/cards.js @@ -1,547 +1,19 @@ -import { useState, useEffect, useRef } from 'react'; -import { useRouter } from 'next/router'; -import Link from 'next/link'; import Layout from '../components/Layout'; -import CardItem from '../components/CardItem'; +import CardsPageView from '../components/CardsPageView'; import PublicCardsView from '../components/PublicCardsView'; -import BulkSelectionToolbar from '../components/BulkSelectionToolbar'; -import CollectionSelectionModal from '../components/CollectionSelectionModal'; import ProtectedRoute from '../components/ProtectedRoute'; -import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols'; -import ManaSymbolSettings from '../components/ManaSymbolSettings'; import { useAuth } from '../lib/use-auth'; -import { VOCAB } from '../lib/collection-vocabulary.js'; +import { useCardsPage } from '../lib/use-cards-page.js'; function AuthenticatedCards() { - const router = useRouter(); - const { user, loading: authLoading } = useAuth(); + const { user } = useAuth(); + const cardsPage = useCardsPage(); - const [cards, setCards] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedTCG, setSelectedTCG] = useState('all'); - const [selectedRarity, setSelectedRarity] = useState('all'); - const [selectedSet, setSelectedSet] = useState('all'); - const [selectedValueRange, setSelectedValueRange] = useState('all'); - const [viewMode, setViewMode] = useState('grid'); // grid or list - const [pagination, setPagination] = useState({ - page: 1, - limit: 50, - total: 0, - pages: 0 - }); - const [filters, setFilters] = useState({ - games: [], - rarities: [], - sets: [] - }); - const [hasMore, setHasMore] = useState(true); - const loadingMoreRef = useRef(false); - const hasMoreRef = useRef(true); - - // Bulk selection state - const [selectedCards, setSelectedCards] = useState([]); - const [favoritedCards, setFavoritedCards] = useState(new Set()); - - // Modal states - const [showCollectionModal, setShowCollectionModal] = useState(false); - const [cardsToAdd, setCardsToAdd] = useState([]); - - // Mana symbol settings - const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); - - // Fetch cards from database - const fetchCards = async (isLoadMore = false) => { - try { - if (isLoadMore) { - setLoadingMore(true); - loadingMoreRef.current = true; - } else { - setLoading(true); - } - - const params = new URLSearchParams({ - query: searchQuery, - game: selectedTCG, - rarity: selectedRarity, - set: selectedSet, - minPrice: selectedValueRange === 'under-50' ? '0' : - selectedValueRange === '50-100' ? '50' : - selectedValueRange === '100-500' ? '100' : - selectedValueRange === '500-1000' ? '500' : - selectedValueRange === 'over-1000' ? '1000' : '', - maxPrice: selectedValueRange === '50-100' ? '100' : - selectedValueRange === '100-500' ? '500' : - selectedValueRange === '500-1000' ? '1000' : '', - page: pagination.page.toString(), - limit: pagination.limit.toString() - }); - - const response = await fetch(`/api/cards/search?${params}`); - const data = await response.json(); - - if (data.success) { - - - if (isLoadMore) { - setCards(prevCards => [...prevCards, ...data.cards]); - } else { - setCards(data.cards); - } - setPagination(data.pagination); - setFilters(data.filters); - const hasMoreCards = data.pagination.page < data.pagination.pages; - setHasMore(hasMoreCards); - hasMoreRef.current = hasMoreCards; - } else { - console.error('Failed to fetch cards:', data.error); - if (!isLoadMore) { - setCards([]); - } - } - } catch (error) { - console.error('Error fetching cards:', error); - if (!isLoadMore) { - setCards([]); - } - } finally { - setLoading(false); - setLoadingMore(false); - loadingMoreRef.current = false; - } - }; - - // Load user's favorited cards - const loadFavoritedCards = async () => { - try { - const token = localStorage.getItem('auth_token'); - if (!token) return; - - const response = await fetch('/api/favorites?type=card', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (response.ok) { - const data = await response.json(); - const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id))); - setFavoritedCards(favoriteIds); - } - } catch (error) { - console.error('Error loading favorited cards:', error); - } - }; - - // Initial load - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- reset list state when filters change, then fetch */ - setPagination(prev => ({ ...prev, page: 1 })); - setCards([]); - setHasMore(true); - hasMoreRef.current = true; - fetchCards(false); - loadFavoritedCards(); // Load user's favorites - /* eslint-enable react-hooks/set-state-in-effect */ - // eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state - }, [selectedTCG, selectedRarity, selectedSet, selectedValueRange]); - - // Handle search with debounce - const [searchTimeout, setSearchTimeout] = useState(null); - - const handleSearchChange = (value) => { - setSearchQuery(value); - - // Clear existing timeout - if (searchTimeout) { - clearTimeout(searchTimeout); - } - - // Set new timeout for search - const newTimeout = setTimeout(() => { - setPagination(prev => ({ ...prev, page: 1 })); - setCards([]); - setHasMore(true); - hasMoreRef.current = true; - fetchCards(false); - }, 500); // 500ms delay - - setSearchTimeout(newTimeout); - }; - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (searchTimeout) { - clearTimeout(searchTimeout); - } - }; - }, [searchTimeout]); - - // Load more cards - const loadMoreCards = async () => { - - - if (!loadingMoreRef.current && hasMoreRef.current) { - const nextPage = pagination.page + 1; - - setPagination(prev => ({ ...prev, page: nextPage })); - - // Use the next page number directly in the fetch - try { - setLoadingMore(true); - loadingMoreRef.current = true; - - const params = new URLSearchParams({ - query: searchQuery, - game: selectedTCG, - rarity: selectedRarity, - set: selectedSet, - minPrice: selectedValueRange === 'under-50' ? '0' : - selectedValueRange === '50-100' ? '50' : - selectedValueRange === '100-500' ? '100' : - selectedValueRange === '500-1000' ? '500' : - selectedValueRange === 'over-1000' ? '1000' : '', - maxPrice: selectedValueRange === '50-100' ? '100' : - selectedValueRange === '100-500' ? '500' : - selectedValueRange === '500-1000' ? '1000' : '', - page: nextPage.toString(), - limit: pagination.limit.toString() - }); - - const response = await fetch(`/api/cards/search?${params}`); - const data = await response.json(); - - if (data.success) { - - setCards(prevCards => [...prevCards, ...data.cards]); - setPagination(data.pagination); - setFilters(data.filters); - const hasMoreCards = data.pagination.page < data.pagination.pages; - setHasMore(hasMoreCards); - hasMoreRef.current = hasMoreCards; - } else { - console.error('Failed to fetch more cards:', data.error); - } - } catch (error) { - console.error('Error fetching more cards:', error); - } finally { - setLoadingMore(false); - loadingMoreRef.current = false; - } - } - }; - - // Update refs when state changes - useEffect(() => { - hasMoreRef.current = hasMore; - loadingMoreRef.current = loadingMore; - }, [hasMore, loadingMore]); - - // Intersection Observer for infinite scroll - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) { - loadMoreCards(); - } - }); - }, - { - rootMargin: '50px', - threshold: 0.1 - } - ); - - const setupObserver = () => { - const sentinel = document.getElementById('infinite-scroll-sentinel'); - if (sentinel) { - observer.observe(sentinel); - } else { - setTimeout(setupObserver, 100); - } - }; - - setupObserver(); - - return () => { - const sentinel = document.getElementById('infinite-scroll-sentinel'); - if (sentinel) { - observer.unobserve(sentinel); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- observer setup once; loadMoreCards uses refs for latest state - }, []); - - const getRarityColor = (rarity) => { - // Map database rarity values to colors - const rarityColorMap = { - 'common': '#6B7280', - 'uncommon': '#10B981', - 'rare': '#F59E0B', - 'mythic': '#FFD700', - 'holographic': '#FF6B6B', - 'enchanted': '#A855F7', - 'secret rare': '#FF6B6B', - 'ultra rare': '#A855F7' - }; - return rarityColorMap[rarity?.toLowerCase()] || '#6B7280'; - }; - - const tcgOptions = [ - { value: 'MTG', label: 'Magic: The Gathering', color: 'purple', icon: '🔮' }, - { value: 'Pokemon', label: 'Pokemon', color: 'blue', icon: '⚡' }, - { value: 'Lorcana', label: 'Disney Lorcana', color: 'pink', icon: '✨' } - ]; - - const rarityOptions = [ - { value: 'all', label: 'All Rarities' }, - { value: 'common', label: 'Common' }, - { value: 'uncommon', label: 'Uncommon' }, - { value: 'rare', label: 'Rare' }, - { value: 'mythic', label: 'Mythic' }, - { value: 'enchanted', label: 'Enchanted' }, - ...filters.rarities.filter(rarity => - !['all', 'common', 'uncommon', 'rare', 'mythic', 'enchanted'].includes(rarity) - ).map(rarity => ({ - value: rarity, - label: rarity, - color: getRarityColor(rarity) - })) - ]; - - const setOptions = [ - { value: 'all', label: 'All Sets' }, - ...filters.sets.map(set => ({ - value: set, - label: set - })) - ]; - - const valueRangeOptions = [ - { value: 'all', label: 'All Values' }, - { value: 'under-50', label: 'Under $50' }, - { value: '50-100', label: '$50 - $100' }, - { value: '100-500', label: '$100 - $500' }, - { value: '500-1000', label: '$500 - $1,000' }, - { value: 'over-1000', label: 'Over $1,000' } - ]; - - - - const formatCurrency = (amount) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD' - }).format(amount); - }; - - const getRarityLabel = (rarity) => { - const rarityOption = rarityOptions.find(option => option.value === rarity); - return rarityOption ? rarityOption.label : rarity; - }; - - // Bulk selection handlers - const handleToggleSelect = (card) => { - setSelectedCards(prev => { - const isSelected = prev.some(c => c.id === card.id); - if (isSelected) { - return prev.filter(c => c.id !== card.id); - } else { - return [...prev, card]; - } - }); - }; - - const handleClearSelection = () => { - setSelectedCards([]); - }; - - const handleToggleFavorite = async (card) => { - try { - const isFavorited = favoritedCards.has(card.id); - const method = isFavorited ? 'DELETE' : 'POST'; - - const response = await fetch('/api/favorites', { - method, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify({ - itemType: 'card', - itemId: card.id - }) - }); - - if (response.ok) { - setFavoritedCards(prev => { - const newSet = new Set(prev); - if (isFavorited) { - newSet.delete(card.id); - } else { - newSet.add(card.id); - } - return newSet; - }); - } - } catch (error) { - console.error('Error toggling favorite:', error); - } - }; - - // Bulk action handlers - const handleBulkAddToCollection = (cards) => { - setCardsToAdd(cards); - setShowCollectionModal(true); - }; - - const handleBulkAddToDeck = (cards) => { - console.log('Adding to deck:', cards); - alert(`Adding ${cards.length} cards to deck (functionality coming soon)`); - }; - - const handleBulkMarkAsOwned = async (cards) => { - try { - const token = localStorage.getItem('auth_token'); - if (!token) { - alert(`Please log in to ${VOCAB.ADD_TO_MY_COLLECTION.toLowerCase()}`); - return; - } - - let successCount = 0; - for (const card of cards) { - try { - const response = await fetch(`/api/cards/${card.id}/ownership`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ quantity: 1 }) - }); - - if (response.ok) { - successCount++; - } - } catch (error) { - console.error(`Error marking card ${card.id} as owned:`, error); - } - } - - if (successCount === cards.length) { - alert(`Successfully added ${cards.length} cards to ${VOCAB.MY_COLLECTION}!`); - } else { - alert(`Added ${successCount} out of ${cards.length} cards to ${VOCAB.MY_COLLECTION}. Some operations may have failed.`); - } - - // Clear selection after successful operation - setSelectedCards([]); - } catch (error) { - console.error('Error in bulk mark as owned:', error); - alert(`Failed to add cards to ${VOCAB.MY_COLLECTION}. Please try again.`); - } - }; - - const handleBulkRemoveFromOwned = async (cards) => { - try { - const token = localStorage.getItem('auth_token'); - if (!token) { - alert(`Please log in to ${VOCAB.REMOVE_FROM_MY_COLLECTION.toLowerCase()}`); - return; - } - - let successCount = 0; - for (const card of cards) { - try { - const response = await fetch(`/api/cards/${card.id}/ownership`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ quantity: 0 }) - }); - - if (response.ok) { - successCount++; - } - } catch (error) { - console.error(`Error removing card ${card.id} from owned:`, error); - } - } - - if (successCount === cards.length) { - alert(`Successfully removed ${cards.length} cards from ${VOCAB.MY_COLLECTION}!`); - } else { - alert(`Removed ${successCount} out of ${cards.length} cards from ${VOCAB.MY_COLLECTION}. Some operations may have failed.`); - } - - // Clear selection after successful operation - setSelectedCards([]); - } catch (error) { - console.error('Error in bulk remove from owned:', error); - alert(`Failed to remove cards from ${VOCAB.MY_COLLECTION}. Please try again.`); - } - }; - - const handleBulkFavorite = async (cards) => { - try { - for (const card of cards) { - if (!favoritedCards.has(card.id)) { - await fetch('/api/favorites', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify({ - itemType: 'card', - itemId: card.id - }) - }); - } - } - - setFavoritedCards(prev => { - const newSet = new Set(prev); - cards.forEach(card => newSet.add(card.id)); - return newSet; - }); - - alert(`Added ${cards.length} cards to favorites`); - } catch (error) { - console.error('Error bulk favoriting:', error); - } - }; - - const handleBulkDelete = (cards) => { - console.log('Bulk delete:', cards); - alert(`Bulk delete functionality coming soon for ${cards.length} cards`); - }; - - // Collection modal handlers - const handleAddToCollections = (results, selectedCollectionIds, cards) => { - const successCount = results.filter(r => r.success).length; - const totalAttempts = results.length; - - if (successCount === totalAttempts) { - alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} list${selectedCollectionIds.length !== 1 ? 's' : ''}!`); - } else { - alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`); - } - - // Clear selection after successful addition - setSelectedCards([]); - }; - - if (loading && cards.length === 0) { + if (cardsPage.showInitialLoading) { return (
-
+
); @@ -549,356 +21,7 @@ function AuthenticatedCards() { return ( - {/* Header */} -
-
-
-

- Cards -

-

- Browse and manage your card collection -

-
-
- - -
-
-
- - {/* Quick Filters */} -
-
-

- Quick Filters -

-
- - {tcgOptions.map(option => ( - - ))} -
-
- - {/* Advanced Filters */} -
-
- -
- handleSearchChange(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - setPagination(prev => ({ ...prev, page: 1 })); - setCards([]); - setHasMore(true); - hasMoreRef.current = true; - fetchCards(false); - } - }} - /> - -
-
-
- - -
-
- - -
-
- - -
-
- - {/* Active Filters Display */} - {(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && ( -
-
- {selectedTCG !== 'all' && ( - - {tcgOptions.find(opt => opt.value === selectedTCG)?.label} - - - )} - {selectedRarity !== 'all' && ( - - {rarityOptions.find(opt => opt.value === selectedRarity)?.label} - - - )} - {selectedSet !== 'all' && ( - - {setOptions.find(opt => opt.value === selectedSet)?.label} - - - )} - {selectedValueRange !== 'all' && ( - - {valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label} - - - )} - -
-
- )} -
- - {/* Cards Display */} -
- {cards.length === 0 ? ( -
-
🃏
-

- No cards found -

-

- Try adjusting your search or filters -

-
- ) : ( - <> -
- {cards.map((card) => ( - c.id === card.id)} - onToggleSelect={handleToggleSelect} - onAddToCollection={(card) => handleBulkAddToCollection([card])} - onAddToDeck={handleBulkAddToDeck} - onToggleFavorite={handleToggleFavorite} - isFavorited={favoritedCards.has(card.id)} - /> - ))} -
- - {/* Infinite Scroll Loading Indicator */} - {loadingMore && ( -
-
-
- Loading more cards... -
-
- )} - - {/* Infinite Scroll Sentinel */} -
- - {/* Load More Button */} - {hasMore && !loadingMore && ( -
- -
- )} - - {/* End of results indicator */} - {!hasMore && cards.length > 0 && ( -
- No more cards to load -
- )} - - - - )} -
- - {/* Bulk Selection Toolbar */} - - - {/* Collection Selection Modal */} - setShowCollectionModal(false)} - cards={cardsToAdd} - onAddToCollections={handleAddToCollections} - /> + ); }