deckhearth/pages/cards.js
Randall Stillwell e75a4a6649 Major redesign: Enhanced card display with particle effects, improved filters, and search functionality
- Redesigned card display with 2.5:3.5 aspect ratio and image-only view
- Added infinite scroll to replace pagination
- Implemented authentic card back placeholders for MTG, Pokemon, and Lorcana
- Added rarity-based particle effects with tiered intensity (mythic/enchanted/rare/uncommon)
- Enhanced hover details panel with structured card information
- Fixed search functionality with debouncing and Enter key support
- Improved filter system with working TCG, rarity, set, and price filters
- Added favorite system for cards in both hover and detail views
- Updated card detail page with comprehensive metadata and actions
- Fixed API filtering with proper Vercel Postgres implementation
- Added particle animations and rarity glow effects
- Improved overall UX with better visual hierarchy and interactions
2025-07-23 21:26:54 -05:00

1171 lines
No EOL
46 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 Layout from '../components/Layout';
export default function Cards() {
const router = useRouter();
const user = {
email: 'me@randallstillwell.com',
role: 'user'
};
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);
// 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;
}
};
// Initial load
useEffect(() => {
setPagination(prev => ({ ...prev, page: 1 }));
setCards([]);
setHasMore(true);
hasMoreRef.current = true;
fetchCards(false);
}, [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);
}
};
}, []);
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;
};
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-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Cards
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Browse and manage your card collection
</p>
</div>
<div className="flex 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-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="mb-4">
<h3 className="text-lg font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
Quick Filters
</h3>
<div className="flex flex-wrap 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 md: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' ? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-6 p-6' : 'space-y-4'}>
{cards.map(card => (
<div key={card.id} onClick={() => router.push(`/card/${card.id}`)} className="flex justify-center">
<Card3D
card={card}
viewMode={viewMode}
getRarityLabel={getRarityLabel}
getRarityColor={getRarityColor}
/>
</div>
))}
</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>
</Layout>
);
}
// 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 (
<div
className="card hover:shadow-xl transition-all duration-300 cursor-pointer"
style={{
transition: 'all 0.3s ease'
}}
>
<div className="flex items-center space-x-4">
<div className="w-16 h-24 rounded-lg overflow-hidden" style={{
background: `linear-gradient(135deg, ${getRarityGradient(card.rarity).replace('from-', '').replace('to-', '')})`,
boxShadow: `0 4px 8px rgba(0, 0, 0, 0.3)`
}}>
<div className="w-full h-full flex items-center justify-center text-white font-bold text-xs">
{card.game}
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
<p className="text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
{card.set_name} {getRarityLabel(card.rarity)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{card.oracle_text || card.card_type}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold gradient-text-purple">
{formatCurrency(card.current_price || 0)}
</p>
<span
className="px-2 py-1 text-xs rounded-full text-white font-medium"
style={{ backgroundColor: getRarityColor(card.rarity) }}
>
{getRarityLabel(card.rarity)}
</span>
</div>
</div>
</div>
);
}
return (
<div className="relative">
{/* Card Container with proper ratio and rarity glow */}
<div
className="relative cursor-pointer transition-transform duration-300 hover:scale-105"
style={{
width: '100%',
maxWidth: '200px',
aspectRatio: '5/7',
boxShadow: getRarityGlow(card.rarity),
borderRadius: '12px',
border: `2px solid ${getRarityColor(card.rarity)}20`,
overflow: 'hidden'
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
{/* Particle Effects for All Rarities */}
{card.rarity !== 'common' && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* 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 */}
<div className="absolute inset-0">
{[...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 (
<div
key={i}
className="absolute w-1 h-1 rounded-full"
style={{
left,
top,
backgroundColor: config.particleColor,
animation: `edgeFloat ${4 + i * 0.3}s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`,
opacity: 0.7,
filter: 'blur(1px)',
boxShadow: `0 0 8px ${config.particleColor}, 0 0 16px ${config.particleColor}, 0 0 24px ${config.particleColor}`,
mixBlendMode: 'screen'
}}
/>
);
})}
</div>
{/* Sparkle Effects - Corner highlights */}
<div className="absolute inset-0">
{[...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 (
<div
key={`sparkle-${i}`}
className="absolute w-0.5 h-0.5"
style={{
left,
top,
backgroundColor: config.sparkleColor,
animation: `sparkle ${2 + i * 0.4}s ease-in-out infinite`,
animationDelay: `${i * 0.2}s`,
opacity: 0.9,
filter: 'blur(0.5px)',
boxShadow: `0 0 4px ${config.sparkleColor}, 0 0 8px ${config.sparkleColor}`,
mixBlendMode: 'screen'
}}
/>
);
})}
</div>
{/* Rarity Aura - Blend with overall glow */}
<div
className="absolute inset-0 rounded-lg"
style={{
background: `radial-gradient(circle at 50% 50%, ${config.glowColor}20 0%, ${config.glowColor}10 40%, transparent 70%)`,
animation: 'aura 4s ease-in-out infinite',
mixBlendMode: 'screen'
}}
/>
</>
);
})()}
</div>
)}
{/* Card Image */}
<div className="w-full h-full">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
{/* Card Back placeholder when no image */}
<div
className={`w-full h-full ${!card.image_url ? 'block' : 'hidden'}`}
style={{ borderRadius: '10px' }}
>
<CardBack game={card.game} />
</div>
</div>
{/* Owned Quantity Chip - Only show if owned */}
{card.quantity > 0 && (
<div className="absolute bottom-0 left-0">
<div
className="px-3 py-1 text-xs font-bold text-white shadow-lg"
style={{
backgroundColor: 'var(--text-accent)',
borderRadius: '8px 8px 0 0',
borderTop: '2px solid var(--text-accent)',
borderLeft: '2px solid var(--text-accent)',
borderRight: '2px solid var(--text-accent)',
transform: 'translateY(2px)'
}}
>
{card.quantity}
</div>
</div>
)}
{/* Favorite Button */}
<div className="absolute top-2 right-2">
<button
onClick={(e) => {
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 ? '❤️' : '🤍'}
</button>
</div>
</div>
{/* Hover Details Panel */}
{isHovered && (
<div
className="fixed z-50 bg-black bg-opacity-90 text-white p-4 rounded-lg shadow-2xl max-w-sm"
style={{
left: cardPosition.x + cardPosition.width,
top: cardPosition.y,
pointerEvents: 'none',
minHeight: cardPosition.height,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<div className="space-y-3">
{/* Card Title */}
<div>
<div className="text-sm font-bold text-white mb-1">{card.name}</div>
</div>
{/* Top Section - TCG, Set, Type, HP in 2x2 grid */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-bold text-white mb-1">TCG</div>
<div className="text-xs text-white">{card.game}</div>
</div>
<div>
<div className="text-xs font-bold text-white mb-1">Set</div>
<div className="text-xs text-white">{card.set_name}</div>
</div>
{card.type && (
<div>
<div className="text-xs font-bold text-white mb-1">Type</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && card.type === 'Lightning' && (
<span className="text-yellow-400"></span>
)}
{card.type}
</div>
</div>
)}
{card.hp && (
<div>
<div className="text-xs font-bold text-white mb-1">HP</div>
<div className="text-xs text-white">{card.hp}</div>
</div>
)}
</div>
{/* Card Type and Form */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-bold text-white mb-1">Card type</div>
<div className="text-xs text-white">{card.card_type || 'Card'}</div>
</div>
{card.form && (
<div>
<div className="text-xs font-bold text-white mb-1">Form</div>
<div className="text-xs text-white">{card.form}</div>
</div>
)}
</div>
{/* Cost to Play */}
{card.mana_cost && (
<div>
<div className="text-xs font-bold text-white mb-1">Cost to Play</div>
<div className="text-xs text-white">{card.mana_cost}</div>
</div>
)}
{/* Card Rule Section */}
{card.game === 'Pokemon' && card.form && card.form.includes('V') && (
<div>
<div className="text-xs font-bold text-white mb-1">Card rule</div>
<div className="text-xs text-white leading-relaxed">
V rule: When your Pokémon V is Knocked Out, your opponent takes 2 Prize cards.
</div>
</div>
)}
{/* Abilities/Attacks/Moves Section */}
{card.oracle_text && (
<div>
<div className="text-xs font-bold text-white mb-1">Card Text</div>
<div className="text-xs text-white leading-relaxed">{card.oracle_text}</div>
</div>
)}
{/* Quote Section */}
{card.flavor_text && (
<div>
<div className="text-xs font-bold text-white mb-1">Quote</div>
<div className="text-xs text-white italic leading-relaxed">"{card.flavor_text}"</div>
</div>
)}
{/* Current Price */}
{card.current_price && (
<div>
<div className="text-xs font-bold text-white mb-1">Current Price</div>
<div className="text-xs text-white">{formatCurrency(card.current_price)}</div>
</div>
)}
{/* Bottom Section - Weaknesses and Retreat */}
<div className="grid grid-cols-2 gap-3">
{card.weakness && (
<div>
<div className="text-xs font-bold text-white mb-1">Weaknesses</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && (
<span className="text-red-500">👊</span>
)}
{card.weakness}
</div>
</div>
)}
{card.retreat_cost && (
<div>
<div className="text-xs font-bold text-white mb-1">Retreat</div>
<div className="text-xs text-white flex items-center gap-1">
{card.game === 'Pokemon' && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-400"></span>
</>
)}
{card.retreat_cost}
</div>
</div>
)}
</div>
{/* Decks and Collections */}
{(card.decks && card.decks.length > 0) || (card.collections && card.collections.length > 0) && (
<div>
<div className="text-xs font-bold text-white mb-1">Included In</div>
<div className="text-xs text-white">
{card.decks && card.decks.length > 0 && (
<div className="mb-1">
<span className="font-semibold">Decks:</span> {card.decks.join(', ')}
</div>
)}
{card.collections && card.collections.length > 0 && (
<div>
<span className="font-semibold">Collections:</span> {card.collections.join(', ')}
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
// 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 (
<div className="w-full h-full rounded-lg overflow-hidden">
<img
src={getCardBackImage()}
alt={`${game} card back`}
className="w-full h-full object-cover"
/>
</div>
);
}