deckhearth/pages/cards.js

975 lines
34 KiB
JavaScript
Raw Normal View History

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 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';
// Public fallback component for non-authenticated users
function PublicCardsView() {
return (
<Layout user={null}>
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header with login prompt */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold mb-4 gradient-text-flame">Browse Trading Cards</h1>
<p className="text-xl mb-6" style={{ color: 'var(--text-secondary)' }}>
Discover thousands of trading cards from popular TCGs
</p>
<div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20 rounded-2xl p-6 border border-orange-200 dark:border-orange-800">
<h3 className="text-lg font-semibold mb-2 gradient-text-ember">Sign Up to Unlock Full Features</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create lists, add cards to {VOCAB.MY_COLLECTION}, mark favorites, and more!
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/signup"
className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Create Free Account
</Link>
<Link
href="/login"
className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Sign In
</Link>
</div>
</div>
</div>
{/* Basic card browser would go here - simplified version */}
<div className="text-center py-20">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-semibold mb-2 gradient-text-flame">Full Card Browser Coming Soon</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Sign up now to get early access to our complete card database and list tools.
</p>
</div>
</div>
</div>
</Layout>
);
}
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>
) : (
<>
⚡ Massive Performance Boost - Remove Heavy Hover Panels 🚀 Performance Optimizations: - Completely removed complex side hover panels (massive DOM reduction) - Eliminated 240+ lines of heavy panel HTML per card - Removed expensive panel positioning calculations - Simplified hover animations from 300ms to 200ms - Removed redundant image scaling (double transform) 🎯 New Lightweight Hover System: - Simple card scaling on hover (transform: scale(1.05)) - Slide-in action buttons in corners - Checkbox & favorite in top-right corner - Ownership indicator & add button in bottom-left - All buttons use opacity transitions (GPU accelerated) 🔥 Fire-Themed Interactive Elements: - Checkbox: Ember red accent color - Favorite: Ember red background when active - Add/Own button: Wood brown background - Ownership indicator: Gold dot when owned - All buttons have subtle hover scaling (scale(1.1)) 📱 Enhanced Grid Layout: - Removed right padding (no more panel space needed) - Increased grid density: lg:4 cols, xl:5 cols, 2xl:6 cols - Better space utilization across all screen sizes - Cleaner, more focused card browsing experience ✨ User Experience Improvements: - Much faster card grid rendering - Smoother hover interactions - Reduced layout shift and jank - Cleaner visual hierarchy - Quick access to essential actions 💾 Code Cleanup: - Removed cardIndex and cardsPerRow props - Eliminated panel positioning logic - Simplified component structure - Reduced bundle size significantly The cards page should now be lightning fast! ⚡🔥
2025-07-26 09:45:38 -04:00
<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>
);
}