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}
- />
+
);
}