deckhearth/pages/cards.js
varutasu 84462417ee
Extract PublicCardsView from pages/cards.js (Brief 2). (#78)
Moves the anonymous /cards landing UI into components/PublicCardsView.js so the page file can focus on AuthenticatedCards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:06:24 -05:00

915 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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';
function AuthenticatedCards() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
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) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
</div>
</Layout>
);
}
return (
<Layout user={user}>
{/* Header */}
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Cards
</h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Browse and manage your card collection
</p>
</div>
<div className="flex space-x-2 sm:space-x-4">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-xl transition-all duration-200 ${
viewMode === 'grid'
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: viewMode === 'grid' ? 'var(--bg-tertiary)' : 'transparent',
color: viewMode === 'grid' ? 'var(--text-primary)' : 'var(--text-secondary)'
}}
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-xl transition-all duration-200 ${
viewMode === 'list'
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: viewMode === 'list' ? 'var(--bg-tertiary)' : 'transparent',
color: viewMode === 'list' ? 'var(--text-primary)' : 'var(--text-secondary)'
}}
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
{/* Quick Filters */}
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="mb-4">
<h3 className="text-base sm:text-lg font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
Quick Filters
</h3>
<div className="flex flex-wrap gap-2 sm:gap-3">
<button
onClick={() => setSelectedTCG('all')}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
selectedTCG === 'all'
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: selectedTCG === 'all' ? 'var(--bg-tertiary)' : 'transparent',
color: selectedTCG === 'all' ? 'var(--text-primary)' : 'var(--text-secondary)',
border: '1px solid var(--border)'
}}
>
All Games
</button>
{tcgOptions.map(option => (
<button
key={option.value}
onClick={() => setSelectedTCG(option.value)}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 ${
selectedTCG === option.value
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: selectedTCG === option.value ? 'var(--bg-tertiary)' : 'transparent',
color: selectedTCG === option.value ? 'var(--text-primary)' : 'var(--text-secondary)',
border: '1px solid var(--border)'
}}
>
<span>{option.icon}</span>
{option.label}
</button>
))}
</div>
</div>
{/* Advanced Filters */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Search
</label>
<div className="relative">
<input
type="text"
placeholder="Search cards..."
className="search-bar w-full pr-12"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
setPagination(prev => ({ ...prev, page: 1 }));
setCards([]);
setHasMore(true);
hasMoreRef.current = true;
fetchCards(false);
}
}}
/>
<button
onClick={() => {
setPagination(prev => ({ ...prev, page: 1 }));
setCards([]);
setHasMore(true);
hasMoreRef.current = true;
fetchCards(false);
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 rounded-lg hover:bg-opacity-20 transition-all duration-200"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Rarity
</label>
<select
value={selectedRarity}
onChange={(e) => setSelectedRarity(e.target.value)}
className="input-field w-full"
>
{rarityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set
</label>
<select
value={selectedSet}
onChange={(e) => setSelectedSet(e.target.value)}
className="input-field w-full"
>
{setOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Value Range
</label>
<select
value={selectedValueRange}
onChange={(e) => setSelectedValueRange(e.target.value)}
className="input-field w-full"
>
{valueRangeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Active Filters Display */}
{(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && (
<div className="mt-4">
<div className="flex flex-wrap gap-2">
{selectedTCG !== 'all' && (
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}>
{tcgOptions.find(opt => opt.value === selectedTCG)?.label}
<button
onClick={() => setSelectedTCG('all')}
className="ml-2 hover:opacity-70"
>
×
</button>
</span>
)}
{selectedRarity !== 'all' && (
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}>
{rarityOptions.find(opt => opt.value === selectedRarity)?.label}
<button
onClick={() => setSelectedRarity('all')}
className="ml-2 hover:opacity-70"
>
×
</button>
</span>
)}
{selectedSet !== 'all' && (
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}>
{setOptions.find(opt => opt.value === selectedSet)?.label}
<button
onClick={() => setSelectedSet('all')}
className="ml-2 hover:opacity-70"
>
×
</button>
</span>
)}
{selectedValueRange !== 'all' && (
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}>
{valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label}
<button
onClick={() => setSelectedValueRange('all')}
className="ml-2 hover:opacity-70"
>
×
</button>
</span>
)}
<button
onClick={() => {
setSelectedTCG('all');
setSelectedRarity('all');
setSelectedSet('all');
setSelectedValueRange('all');
}}
className="px-3 py-1 rounded-full text-sm font-medium hover:opacity-70" style={{
color: 'var(--text-accent)',
border: '1px solid var(--border)'
}}>
Clear All
</button>
</div>
</div>
)}
</div>
{/* Cards Display */}
<div className="p-6 pb-64">
{cards.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No cards found
</h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Try adjusting your search or filters
</p>
</div>
) : (
<>
<div className={viewMode === 'grid' ? 'card-grid-container grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 sm:gap-4 lg:gap-6 p-3 sm:p-4 lg:p-6 xl:p-8' : 'space-y-4 p-6'}>
{cards.map((card) => (
<CardItem
key={card.id}
card={card}
viewMode={viewMode}
isSelected={selectedCards.some(c => c.id === card.id)}
onToggleSelect={handleToggleSelect}
onAddToCollection={(card) => handleBulkAddToCollection([card])}
onAddToDeck={handleBulkAddToDeck}
onToggleFavorite={handleToggleFavorite}
isFavorited={favoritedCards.has(card.id)}
/>
))}
</div>
{/* Infinite Scroll Loading Indicator */}
{loadingMore && (
<div className="flex justify-center py-8">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
<span style={{ color: 'var(--text-secondary)' }}>Loading more cards...</span>
</div>
</div>
)}
{/* Infinite Scroll Sentinel */}
<div id="infinite-scroll-sentinel" className="h-4 w-full" />
{/* Load More Button */}
{hasMore && !loadingMore && (
<div className="flex justify-center py-8">
<button
onClick={loadMoreCards}
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Load More Cards
</button>
</div>
)}
{/* End of results indicator */}
{!hasMore && cards.length > 0 && (
<div className="flex justify-center py-8">
<span style={{ color: 'var(--text-secondary)' }}>No more cards to load</span>
</div>
)}
</>
)}
</div>
{/* Bulk Selection Toolbar */}
<BulkSelectionToolbar
selectedCards={selectedCards}
onClearSelection={handleClearSelection}
onAddToCollection={handleBulkAddToCollection}
onAddToDeck={handleBulkAddToDeck}
onMarkAsOwned={handleBulkMarkAsOwned}
onRemoveFromOwned={handleBulkRemoveFromOwned}
onBulkFavorite={handleBulkFavorite}
onBulkDelete={handleBulkDelete}
/>
{/* Collection Selection Modal */}
<CollectionSelectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
cards={cardsToAdd}
onAddToCollections={handleAddToCollections}
/>
</Layout>
);
}
export default function Cards() {
return (
<ProtectedRoute
allowPublic={true}
publicFallback={<PublicCardsView />}
>
<AuthenticatedCards />
</ProtectedRoute>
);
}