/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
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 (
{/* Header with login prompt */}
Browse Trading Cards
Discover thousands of trading cards from popular TCGs
Sign Up to Unlock Full Features
Create lists, add cards to {VOCAB.MY_COLLECTION}, mark favorites, and more!
Create Free Account
Sign In
{/* Basic card browser would go here - simplified version */}
Full Card Browser Coming Soon
Sign up now to get early access to our complete card database and list tools.
);
}
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 (
);
}
return (
{/* Header */}
Cards
Browse and manage your card collection
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)'
}}
>
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)'
}}
>
{/* Quick Filters */}
Quick Filters
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
{tcgOptions.map(option => (
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)'
}}
>
{option.icon}
{option.label}
))}
{/* Advanced Filters */}
Rarity
setSelectedRarity(e.target.value)}
className="input-field w-full"
>
{rarityOptions.map(option => (
{option.label}
))}
Set
setSelectedSet(e.target.value)}
className="input-field w-full"
>
{setOptions.map(option => (
{option.label}
))}
Value Range
setSelectedValueRange(e.target.value)}
className="input-field w-full"
>
{valueRangeOptions.map(option => (
{option.label}
))}
{/* Active Filters Display */}
{(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && (
{selectedTCG !== 'all' && (
{tcgOptions.find(opt => opt.value === selectedTCG)?.label}
setSelectedTCG('all')}
className="ml-2 hover:opacity-70"
>
×
)}
{selectedRarity !== 'all' && (
{rarityOptions.find(opt => opt.value === selectedRarity)?.label}
setSelectedRarity('all')}
className="ml-2 hover:opacity-70"
>
×
)}
{selectedSet !== 'all' && (
{setOptions.find(opt => opt.value === selectedSet)?.label}
setSelectedSet('all')}
className="ml-2 hover:opacity-70"
>
×
)}
{selectedValueRange !== 'all' && (
{valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label}
setSelectedValueRange('all')}
className="ml-2 hover:opacity-70"
>
×
)}
{
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
)}
{/* 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 && (
)}
{/* Infinite Scroll Sentinel */}
{/* Load More Button */}
{hasMore && !loadingMore && (
Load More Cards
)}
{/* 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}
/>
);
}
export default function Cards() {
return (
}
>
);
}
// 3D Card Component
function Card3D({ card, viewMode, getRarityLabel, getRarityColor }) {
const [isHovered, setIsHovered] = useState(false);
const [cardPosition, setCardPosition] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isFavorited, setIsFavorited] = useState(false);
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
setCardPosition({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
});
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const handleMouseEnter = () => {
setIsHovered(true);
};
const getTCGColor = (game) => {
const colors = {
'MTG': '#8B5CF6', // purple
'Pokemon': '#3B82F6', // blue
'Lorcana': '#EC4899' // pink
};
return colors[game] || '#6B7280';
};
const getSetColor = (setName) => {
// Generate a consistent color based on set name
const hash = setName.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0);
return a & a;
}, 0);
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 60%)`;
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const getRarityGradient = (rarity) => {
const gradients = {
common: 'from-gray-400 to-gray-500',
uncommon: 'from-green-400 to-emerald-500',
rare: 'from-yellow-400 to-orange-500',
mythic: 'from-yellow-400 to-orange-500',
holographic: 'from-red-400 to-pink-500',
enchanted: 'from-purple-400 to-indigo-500',
ultra: 'from-blue-400 to-cyan-500'
};
return gradients[rarity] || 'from-gray-400 to-gray-500';
};
const getRarityGlow = (rarity) => {
const glows = {
common: '0 0 15px rgba(156, 163, 175, 0.4)',
uncommon: '0 0 15px rgba(34, 197, 94, 0.4)',
rare: '0 0 20px rgba(251, 191, 36, 0.5)',
mythic: '0 0 20px rgba(255, 215, 0, 0.4), 0 0 40px rgba(255, 215, 0, 0.2), 0 0 60px rgba(255, 215, 0, 0.1)',
holographic: '0 0 25px rgba(239, 68, 68, 0.6)',
enchanted: '0 0 20px rgba(168, 85, 247, 0.4), 0 0 40px rgba(168, 85, 247, 0.2), 0 0 60px rgba(168, 85, 247, 0.1)',
ultra: '0 0 25px rgba(59, 130, 246, 0.6)'
};
return glows[rarity] || '0 0 15px rgba(156, 163, 175, 0.4)';
};
if (viewMode === 'list') {
return (
{card.name}
{card.set_name} • {getRarityLabel(card.rarity)}
{card.oracle_text || card.card_type}
{formatCurrency(card.current_price || 0)}
{getRarityLabel(card.rarity)}
);
}
return (
{/* Card Container with proper ratio and rarity glow */}
{/* Particle Effects for All Rarities */}
{card.rarity !== 'common' && (
{/* Get particle count and colors based on rarity */}
{(() => {
const rarityConfig = {
'mythic': {
particleCount: 16,
sparkleCount: 10,
particleColor: '#FFD700',
sparkleColor: '#FFA500',
glowColor: '#FFD700'
},
'enchanted': {
particleCount: 14,
sparkleCount: 8,
particleColor: '#A855F7',
sparkleColor: '#EC4899',
glowColor: '#A855F7'
},
'rare': {
particleCount: 10,
sparkleCount: 6,
particleColor: '#3B82F6',
sparkleColor: '#60A5FA',
glowColor: '#3B82F6'
},
'uncommon': {
particleCount: 6,
sparkleCount: 4,
particleColor: '#10B981',
sparkleColor: '#34D399',
glowColor: '#10B981'
}
};
const config = rarityConfig[card.rarity];
if (!config) return null;
return (
<>
{/* Floating Particles - Edge framing */}
{[...Array(config.particleCount)].map((_, i) => {
// Position particles around the card edges
let left, top;
const edgeIndex = i % 4; // 4 edges
const positionOnEdge = Math.floor(i / 4);
if (edgeIndex === 0) {
// Top edge
left = `${10 + (positionOnEdge * 20)}%`;
top = '2%';
} else if (edgeIndex === 1) {
// Right edge
left = '98%';
top = `${10 + (positionOnEdge * 20)}%`;
} else if (edgeIndex === 2) {
// Bottom edge
left = `${10 + (positionOnEdge * 20)}%`;
top = '98%';
} else {
// Left edge
left = '2%';
top = `${10 + (positionOnEdge * 20)}%`;
}
return (
);
})}
{/* Sparkle Effects - Corner highlights */}
{[...Array(config.sparkleCount)].map((_, i) => {
// Position sparkles in the corners and edge centers
let left, top;
if (i < 2) {
// Top corners
left = i === 0 ? '5%' : '95%';
top = '5%';
} else if (i < 4) {
// Bottom corners
left = i === 2 ? '5%' : '95%';
top = '95%';
} else if (i < 6) {
// Edge centers
left = i === 4 ? '50%' : '50%';
top = i === 4 ? '5%' : '95%';
} else {
// Side centers
left = i === 6 ? '5%' : '95%';
top = '50%';
}
return (
);
})}
{/* Rarity Aura - Blend with overall glow */}
>
);
})()}
)}
{/* Card Image */}
{card.image_url ? (
{
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
{/* Card Back placeholder when no image */}
{/* Owned Quantity Chip - Only show if owned */}
{card.quantity > 0 && (
)}
{/* Favorite Button */}
{
e.stopPropagation();
setIsFavorited(!isFavorited);
}}
className={`p-2 rounded-full transition-all duration-200 shadow-lg ${
isFavorited
? 'bg-red-500 bg-opacity-80 text-white'
: 'bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30'
}`}
>
{isFavorited ? '❤️' : '🤍'}
{/* Hover Details Panel */}
{isHovered && (
{/* Card Title */}
{/* Top Section - TCG, Set, Type, HP in 2x2 grid */}
{card.type && (
Type
{card.game === 'Pokemon' && card.type === 'Lightning' && (
⚡
)}
{card.type}
)}
{card.hp && (
)}
{/* Card Type and Form */}
Card type
{card.card_type || 'Card'}
{card.form && (
)}
{/* Cost to Play */}
{card.mana_cost && (
)}
{/* Card Rule Section */}
{card.game === 'Pokemon' && card.form && card.form.includes('V') && (
Card rule
V rule: When your Pokémon V is Knocked Out, your opponent takes 2 Prize cards.
)}
{/* Abilities/Attacks/Moves Section */}
{card.oracle_text && (
Card Text
{card.oracle_text}
)}
{/* Quote Section */}
{card.flavor_text && (
Quote
"{card.flavor_text}"
)}
{/* Current Price */}
{card.current_price && (
Current Price
{formatCurrency(card.current_price)}
)}
{/* Bottom Section - Weaknesses and Retreat */}
{card.weakness && (
Weaknesses
{card.game === 'Pokemon' && (
👊
)}
{card.weakness}
)}
{card.retreat_cost && (
Retreat
{card.game === 'Pokemon' && (
<>
⭐
⭐
>
)}
{card.retreat_cost}
)}
{/* Decks and Collections */}
{(card.decks && card.decks.length > 0) || (card.collections && card.collections.length > 0) && (
Included In
{card.decks && card.decks.length > 0 && (
Decks: {card.decks.join(', ')}
)}
{card.collections && card.collections.length > 0 && (
{VOCAB.LISTS}: {card.collections.join(', ')}
)}
)}
)}
);
}
// Card Back Component for placeholders
function CardBack({ game }) {
const getCardBackImage = () => {
switch (game) {
case 'MTG':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjOEI0NTEzIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNBMDUyMkQiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiM0QTkwRTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPk1BR0lDPC90ZXh0Pgo8dGV4dCB4PSIxMDAiIHk9IjQ1IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5UaGUgR2F0aGVyaW5nPC90ZXh0Pgo8Y2lyY2xlIGN4PSI3MCIgY3k9IjcwIiByPSI0IiBmaWxsPSJ3aGl0ZSIvPgo8Y2lyY2xlIGN4PSIxMzAiIGN4PSI3MCIgcj0iNCIgZmlsbD0iI0Y1OTlFMEIiLz4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iODAiIHI9IjQiIGZpbGw9IiM0QTkwRTIiLz4KPGNpcmNsZSBjeD0iNzAiIGN5PSI5MCIgcj0iNCIgZmlsbD0iIzEwQjk4MSIvPgo8Y2lyY2xlIGN4PSIxMzAiIGN5PSI5MCIgcj0iNCIgZmlsbD0iYmxhY2siLz4KPHRleHQgeD0iMTAwIiB5PSIxMjAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiMyMEIyQUEiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkRFQ0tNQVNURVI8L3RleHQ+Cjwvc3ZnPgo=';
case 'Pokemon':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjRkY2QjZCIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNGRjhFOEUiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiNGRkQ3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlBPS0VNT048L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iNDUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMiIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlRyYWRpbmcgQ2FyZCBHYW1lPC90ZXh0Pgo8Y2lyY2xlIGN4PSIxMDAiIGN5PSI3MCIgcj0iMjAiIGZpbGw9IndoaXRlIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iNzgiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZmlsbD0iIzFGN0Y3RiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+4pePPC90ZXh0Pgo8dGV4dCB4PSIxMDAiIHk9IjEyMCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEwIiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI0ZGRDcwMCIgdGV4dC1hbmNob3I9Im1pZGRsZSI+R0FNRSBGUkVBSzwvdGV4dD4KPC9zdmc+Cg==';
case 'Lorcana':
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjRUM0ODk5Ii8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiNGNDcyQjYiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiNGQkJGMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkRJU05FWTwvdGV4dD4KPHRleHQgeD0iMTAwIiB5PSI0NSIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEyIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+TG9yY2FuYTwvdGV4dD4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iNzAiIHI9IjIwIiBmaWxsPSJ3aGl0ZSIvPgo8dGV4dCB4PSIxMDAiIHk9Ijc4IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIGZpbGw9IiMxRjdGN0YiIHRleHQtYW5jaG9yPSJtaWRkbGUiPvCfkqQ8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iMTIwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjRjU5RTBCIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5SQVZFTlNERVJHPzwvdGV4dD4KPC9zdmc+Cg==';
default:
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0MyIgdmlld0JveD0iMCAwIDIwMCAxNDMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTQzIiBmaWxsPSIjNkI3MjgwIi8+CjxyZWN0IHg9IjEwIiB5PSIxMCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMjMiIGZpbGw9IiM5Q0EzQUYiIHJ4PSI4Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IndoaXRlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5UQ0c8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iNDUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMiIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlRyYWRpbmcgQ2FyZDwvdGV4dD4KPGNpcmNsZSBjeD0iMTAwIiBjeT0iNzAiIHI9IjIwIiBmaWxsPSJ3aGl0ZSIvPgo8dGV4dCB4PSIxMDAiIHk9Ijc4IiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIGZpbGw9IiMxRjdGN0YiIHRleHQtYW5jaG9yPSJtaWRkbGUiPvCfkqQ8L3RleHQ+Cjx0ZXh0IHg9IjEwMCIgeT0iMTIwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjNkI3MjgwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5DQVJEPC90ZXh0Pgo8L3N2Zz4K';
}
};
return (
);
}