2025-07-23 22:26:54 -04:00
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Layout from '../components/Layout';
|
2025-07-26 00:22:15 -04:00
|
|
|
|
import CardItem from '../components/CardItem';
|
|
|
|
|
|
import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
2025-07-26 00:22:15 -04:00
|
|
|
|
|
|
|
|
|
|
// Bulk selection state
|
|
|
|
|
|
const [selectedCards, setSelectedCards] = useState([]);
|
|
|
|
|
|
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-26 00:22:15 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-26 00:22:15 -04:00
|
|
|
|
// Bulk action handlers
|
|
|
|
|
|
const handleBulkAddToCollection = (cards) => {
|
|
|
|
|
|
console.log('Adding to collection:', cards);
|
|
|
|
|
|
alert(`Adding ${cards.length} cards to collection (functionality coming soon)`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkAddToDeck = (cards) => {
|
|
|
|
|
|
console.log('Adding to deck:', cards);
|
|
|
|
|
|
alert(`Adding ${cards.length} cards to deck (functionality coming soon)`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkMarkAsOwned = (cards) => {
|
|
|
|
|
|
console.log('Marking as owned:', cards);
|
|
|
|
|
|
alert(`Marking ${cards.length} cards as owned (functionality coming soon)`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkRemoveFromOwned = (cards) => {
|
|
|
|
|
|
console.log('Removing from owned:', cards);
|
|
|
|
|
|
alert(`Removing ${cards.length} cards from owned (functionality coming soon)`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
|
};
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2025-07-26 00:55:54 -04:00
|
|
|
|
<div className={viewMode === 'grid' ? 'card-grid-container grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-8 p-8 pr-96' : 'space-y-4 p-6'}>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
{cards.map(card => (
|
2025-07-26 00:22:15 -04:00
|
|
|
|
<CardItem
|
|
|
|
|
|
key={card.id}
|
|
|
|
|
|
card={card}
|
|
|
|
|
|
viewMode={viewMode}
|
|
|
|
|
|
isSelected={selectedCards.some(c => c.id === card.id)}
|
|
|
|
|
|
onToggleSelect={handleToggleSelect}
|
|
|
|
|
|
onAddToCollection={handleBulkAddToCollection}
|
|
|
|
|
|
onAddToDeck={handleBulkAddToDeck}
|
|
|
|
|
|
onToggleFavorite={handleToggleFavorite}
|
|
|
|
|
|
isFavorited={favoritedCards.has(card.id)}
|
|
|
|
|
|
/>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
2025-07-26 00:22:15 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Bulk Selection Toolbar */}
|
|
|
|
|
|
<BulkSelectionToolbar
|
|
|
|
|
|
selectedCards={selectedCards}
|
|
|
|
|
|
onClearSelection={handleClearSelection}
|
|
|
|
|
|
onAddToCollection={handleBulkAddToCollection}
|
|
|
|
|
|
onAddToDeck={handleBulkAddToDeck}
|
|
|
|
|
|
onMarkAsOwned={handleBulkMarkAsOwned}
|
|
|
|
|
|
onRemoveFromOwned={handleBulkRemoveFromOwned}
|
|
|
|
|
|
onBulkFavorite={handleBulkFavorite}
|
|
|
|
|
|
onBulkDelete={handleBulkDelete}
|
|
|
|
|
|
/>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</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
|
2025-07-24 15:54:12 -04:00
|
|
|
|
className="relative cursor-pointer transition-transform duration-300 hover:scale-105 w-full"
|
2025-07-23 22:26:54 -04:00
|
|
|
|
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 */}
|
2025-07-24 15:54:12 -04:00
|
|
|
|
<div className="w-full h-full relative">
|
2025-07-23 22:26:54 -04:00
|
|
|
|
{card.image_url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-full h-full object-cover"
|
2025-07-24 15:54:12 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
|
objectPosition: 'center',
|
|
|
|
|
|
borderRadius: '10px'
|
|
|
|
|
|
}}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
onError={(e) => {
|
|
|
|
|
|
e.target.style.display = 'none';
|
|
|
|
|
|
e.target.nextSibling.style.display = 'flex';
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Back placeholder when no image */}
|
|
|
|
|
|
<div
|
2025-07-24 15:54:12 -04:00
|
|
|
|
className={`w-full h-full absolute inset-0 ${!card.image_url ? 'block' : 'hidden'}`}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|