Extract Card3D and CardBack from pages/cards.js (Brief 1). (#77)
Moves ~530 lines of 3D card rendering into components/Card3D.js so the cards page god-component split can continue incrementally. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2273fc6be0
commit
33e03d8f47
2 changed files with 530 additions and 531 deletions
530
components/Card3D.js
Normal file
530
components/Card3D.js
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ManaCost } from './ManaSymbols';
|
||||||
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
|
export default function Card3D({ card, viewMode, getRarityLabel, getRarityColor, useManaSvg = 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 w-full"
|
||||||
|
style={{
|
||||||
|
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 relative">
|
||||||
|
{card.image_url ? (
|
||||||
|
<img
|
||||||
|
src={card.image_url}
|
||||||
|
alt={card.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
style={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
borderRadius: '10px'
|
||||||
|
}}
|
||||||
|
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 absolute inset-0 ${!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>
|
||||||
|
<ManaCost cost={card.mana_cost} size="sm" useSVG={useManaSvg} />
|
||||||
|
</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">{VOCAB.LISTS}:</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
531
pages/cards.js
531
pages/cards.js
|
|
@ -1,4 +1,3 @@
|
||||||
/* 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 { useState, useEffect, useRef } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -973,533 +972,3 @@ export default function Cards() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 w-full"
|
|
||||||
style={{
|
|
||||||
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 relative">
|
|
||||||
{card.image_url ? (
|
|
||||||
<img
|
|
||||||
src={card.image_url}
|
|
||||||
alt={card.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
style={{
|
|
||||||
objectFit: 'cover',
|
|
||||||
objectPosition: 'center',
|
|
||||||
borderRadius: '10px'
|
|
||||||
}}
|
|
||||||
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 absolute inset-0 ${!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>
|
|
||||||
<ManaCost cost={card.mana_cost} size="sm" useSVG={manaSymbolSettings.useSVG} />
|
|
||||||
</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">{VOCAB.LISTS}:</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue