Add comprehensive card data sources integration with external APIs and database browser component

This commit is contained in:
Randall Stillwell 2025-07-22 20:00:10 -05:00
parent 4e71e29922
commit d21394d579
5 changed files with 1321 additions and 10 deletions

231
CARD_DATA_SOURCES.md Normal file
View file

@ -0,0 +1,231 @@
# Card Data Sources Guide
This guide explains how to build out your card database using external APIs and data sources.
## 🎯 **Recommended Data Sources**
### **1. Scryfall API (Magic: The Gathering)**
**Best for MTG cards**
- **URL**: https://api.scryfall.com
- **Free**: No API key required
- **Rate Limit**: 10 requests/second
- **Coverage**: All MTG cards with images, prices, rulings
**Example Usage**:
```javascript
// Search for a card
const response = await fetch('https://api.scryfall.com/cards/named?fuzzy=lightning+bolt');
const card = await response.json();
// Search multiple cards
const searchResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper+type:creature');
const cards = await searchResponse.json();
```
### **2. Pokémon TCG API**
**Best for Pokémon cards**
- **URL**: https://api.pokemontcg.io/v2
- **Free**: No authentication required
- **Rate Limit**: Generous limits
- **Coverage**: All Pokémon cards with images
**Example Usage**:
```javascript
// Search for a card
const response = await fetch('https://api.pokemontcg.io/v2/cards?q=name:pikachu');
const cards = await response.json();
```
### **3. Yu-Gi-Oh! API**
**Best for Yu-Gi-Oh! cards**
- **URL**: https://db.ygoprodeck.com/api/v7
- **Free**: Open source API
- **Coverage**: All Yu-Gi-Oh! cards
**Example Usage**:
```javascript
// Search for a card
const response = await fetch('https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=Blue-Eyes%20White%20Dragon');
const cards = await response.json();
```
### **4. Disney Lorcana**
**For Lorcana cards**
- **Status**: No official API available yet
- **Alternative**: Manual data entry or community data scraping
- **Future**: May have official API when game matures
## 🚀 **Implementation Strategy**
### **Phase 1: Core Integration**
1. **Set up rate limiting** for each API
2. **Create unified search interface** that queries all sources
3. **Implement caching** to avoid repeated requests
4. **Add error handling** for API failures
### **Phase 2: Database Population**
1. **Fetch popular cards** from each game
2. **Store in local database** with proper metadata
3. **Update pricing** regularly
4. **Add image caching** for better performance
### **Phase 3: Advanced Features**
1. **Real-time pricing** from multiple sources
2. **Set completion tracking**
3. **Market trend analysis**
4. **Deck building suggestions**
## 📊 **Data Mapping**
### **Universal Fields**
| Field | Description | Source |
|-------|-------------|--------|
| `name` | Card name | All APIs |
| `game` | Game identifier | All APIs |
| `set_name` | Set/expansion name | All APIs |
| `set_code` | Set abbreviation | All APIs |
| `card_number` | Collector number | All APIs |
| `rarity` | Rarity level | All APIs |
| `image_url` | Card image URL | All APIs |
| `current_price` | Market price | MTG, Pokémon |
### **Game-Specific Fields**
#### **Magic: The Gathering**
- `mana_cost`: Mana symbols `{W}{U}{B}{R}{G}{C}`
- `cmc`: Converted mana cost
- `card_type`: Full type line
- `colors`: Color identity
- `oracle_text`: Rules text
- `power`/`toughness`: Creature stats
#### **Pokémon**
- `mana_cost`: Energy requirements
- `cmc`: Total energy cost
- `card_type`: Card type + stage
- `colors`: Energy types
- `power`: HP value
- `oracle_text`: Effect text
#### **Yu-Gi-Oh!**
- `mana_cost`: Level/rank
- `cmc`: Level value
- `card_type`: Card type
- `colors`: Attribute
- `oracle_text`: Effect text
- `power`/`toughness`: ATK/DEF
## 🛠️ **Usage Examples**
### **Search Cards from External APIs**
```javascript
import cardDataService from '../services/cardDataSources';
// Search across all games
const cards = await cardDataService.searchCards('dragon');
// Search specific game
const mtgCards = await cardDataService.searchCards('lightning', 'MTG');
```
### **Add Cards to Database**
```javascript
// Fetch card from external API
const card = await mtgService.getCardByName('Lightning Bolt');
// Add to local database
const result = await databaseAPI.addCard(card);
```
### **Populate Database with Popular Cards**
```bash
# Run the population script
node scripts/populate-card-database.js
```
## 📈 **Performance Considerations**
### **Rate Limiting**
- **Scryfall**: 10 requests/second
- **Pokémon API**: 5 requests/second
- **Yu-Gi-Oh! API**: No strict limits
### **Caching Strategy**
- **Search results**: Cache for 5 minutes
- **Card details**: Cache for 1 hour
- **Images**: Cache indefinitely
- **Pricing**: Update every 24 hours
### **Error Handling**
- **API failures**: Retry with exponential backoff
- **Rate limits**: Implement proper queuing
- **Network issues**: Graceful degradation
## 🔧 **Setup Instructions**
### **1. Install Dependencies**
```bash
npm install node-fetch
```
### **2. Set Environment Variables**
```bash
# .env
REACT_APP_API_URL=http://localhost:8000
TCG_VAULT_TOKEN=your_auth_token_here
```
### **3. Run Database Population**
```bash
# Populate with popular cards
node scripts/populate-card-database.js
# Or use the web interface
# Navigate to Cards page → "Browse Database" button
```
### **4. Test the Integration**
```javascript
// Test external API search
const cards = await cardDataService.searchCards('pikachu', 'POKEMON');
console.log('Found cards:', cards.length);
// Test database addition
const card = await mtgService.getCardByName('Lightning Bolt');
if (card) {
await databaseAPI.addCard(card);
console.log('Card added to database');
}
```
## 🎯 **Next Steps**
### **Immediate Actions**
1. **Test the APIs** with the provided services
2. **Populate your database** with popular cards
3. **Integrate the browser component** into your app
4. **Set up regular updates** for pricing data
### **Future Enhancements**
1. **Add more games** (Lorcana, Flesh and Blood, etc.)
2. **Implement price tracking** with alerts
3. **Add set completion tracking**
4. **Create deck building suggestions**
5. **Add market analysis tools**
## 📚 **Additional Resources**
- **Scryfall API Docs**: https://scryfall.com/docs/api
- **Pokémon TCG API**: https://dev.pokemontcg.io/
- **Yu-Gi-Oh! API**: https://ygoprodeck.com/api-guide/
- **TCGPlayer API**: https://docs.tcgplayer.com/
## 🚨 **Important Notes**
1. **Respect rate limits** - Implement proper throttling
2. **Cache responses** - Avoid unnecessary API calls
3. **Handle errors gracefully** - APIs can be unreliable
4. **Update pricing regularly** - Card values change frequently
5. **Verify card data** - Cross-reference with official sources
This implementation provides a solid foundation for building a comprehensive card database that can grow with your application's needs!

View file

@ -0,0 +1,328 @@
#!/usr/bin/env node
/**
* Card Database Population Script
*
* This script fetches cards from external APIs and adds them to your local database.
* Run this to populate your database with popular cards from various games.
*/
const fetch = require('node-fetch');
// Configuration
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const AUTH_TOKEN = process.env.TCG_VAULT_TOKEN; // Set this in your environment
// Popular cards to fetch (examples)
const POPULAR_CARDS = {
MTG: [
'Lightning Bolt',
'Black Lotus',
'Counterspell',
'Dark Ritual',
'Brainstorm',
'Force of Will',
'Wasteland',
'Tarmogoyf',
'Snapcaster Mage',
'Jace, the Mind Sculptor'
],
POKEMON: [
'Pikachu',
'Charizard',
'Blastoise',
'Venusaur',
'Mewtwo',
'Lugia',
'Ho-Oh',
'Rayquaza',
'Garchomp',
'Lucario'
],
YUGIOH: [
'Blue-Eyes White Dragon',
'Dark Magician',
'Red-Eyes Black Dragon',
'Exodia the Forbidden One',
'Slifer the Sky Dragon',
'Obelisk the Tormentor',
'The Winged Dragon of Ra',
'Elemental HERO Neos',
'Stardust Dragon',
'Number 39: Utopia'
]
};
// Rate limiting utility
class RateLimiter {
constructor(maxRequests, timeWindow) {
this.maxRequests = maxRequests;
this.timeWindow = timeWindow;
this.requests = [];
}
async waitForSlot() {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < this.timeWindow);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.timeWindow - (now - oldestRequest);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requests.push(now);
}
}
const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second
const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second
// API Services
const mtgService = {
async getCardByName(name) {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(
`https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(name)}`
);
if (!response.ok) {
console.log(`❌ MTG card not found: ${name}`);
return null;
}
const card = await response.json();
return {
name: card.name,
game: 'MTG',
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
};
} catch (error) {
console.error(`❌ Error fetching MTG card ${name}:`, error.message);
return null;
}
}
};
const pokemonService = {
async getCardByName(name) {
await pokemonLimiter.waitForSlot();
try {
const response = await fetch(
`https://api.pokemontcg.io/v2/cards?q=name:${encodeURIComponent(name)}&pageSize=1`
);
if (!response.ok) {
console.log(`❌ Pokémon card not found: ${name}`);
return null;
}
const data = await response.json();
if (data.data.length === 0) {
console.log(`❌ Pokémon card not found: ${name}`);
return null;
}
const card = data.data[0];
return {
name: card.name,
game: 'POKEMON',
set_name: card.set.name,
set_code: card.set.id,
card_number: card.number,
rarity: card.rarity,
mana_cost: card.convertedRetreatCost?.toString(),
cmc: card.convertedRetreatCost,
card_type: card.supertype,
colors: card.types || [],
oracle_text: card.attacks?.map(attack => `${attack.name}: ${attack.text}`).join('\n'),
power: card.hp,
toughness: null,
image_url: card.images.small,
stock_image_url: card.images.large,
current_price: card.cardmarket?.prices?.averageSellPrice || null,
market_price: card.cardmarket?.prices?.lowPrice || null,
};
} catch (error) {
console.error(`❌ Error fetching Pokémon card ${name}:`, error.message);
return null;
}
}
};
const yugiohService = {
async getCardByName(name) {
try {
const response = await fetch(
`https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=${encodeURIComponent(name)}`
);
if (!response.ok) {
console.log(`❌ Yu-Gi-Oh! card not found: ${name}`);
return null;
}
const data = await response.json();
if (data.data.length === 0) {
console.log(`❌ Yu-Gi-Oh! card not found: ${name}`);
return null;
}
const card = data.data[0];
return {
name: card.name,
game: 'YUGIOH',
set_name: card.set_name,
set_code: card.set_code,
card_number: card.num,
rarity: card.rarity,
mana_cost: card.level?.toString(),
cmc: card.level,
card_type: card.type,
colors: [card.attribute],
oracle_text: card.desc,
power: card.atk?.toString(),
toughness: card.def?.toString(),
image_url: card.card_images?.[0]?.image_url,
stock_image_url: card.card_images?.[0]?.image_url_small,
current_price: card.card_prices?.[0]?.amazon_price ? parseFloat(card.card_prices[0].amazon_price) : null,
market_price: card.card_prices?.[0]?.cardmarket_price ? parseFloat(card.card_prices[0].cardmarket_price) : null,
};
} catch (error) {
console.error(`❌ Error fetching Yu-Gi-Oh! card ${name}:`, error.message);
return null;
}
}
};
// Database API
const databaseAPI = {
async addCard(cardData) {
try {
const response = await fetch(`${API_BASE_URL}/api/cards/find-or-create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(AUTH_TOKEN && { 'Authorization': `Bearer ${AUTH_TOKEN}` }),
},
body: JSON.stringify({
name: cardData.name,
game: cardData.game,
setName: cardData.set_name,
setCode: cardData.set_code,
rarity: cardData.rarity,
cardType: cardData.card_type,
manaCost: cardData.mana_cost,
imageUrl: cardData.stock_image_url,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error(`❌ Error adding card to database:`, error.message);
return null;
}
}
};
// Main execution
async function populateDatabase() {
console.log('🚀 Starting card database population...\n');
let totalProcessed = 0;
let totalAdded = 0;
let totalErrors = 0;
for (const [game, cards] of Object.entries(POPULAR_CARDS)) {
console.log(`📦 Processing ${game} cards...`);
for (const cardName of cards) {
totalProcessed++;
console.log(` 🔍 Fetching: ${cardName}`);
let cardData = null;
// Fetch card data based on game
switch (game) {
case 'MTG':
cardData = await mtgService.getCardByName(cardName);
break;
case 'POKEMON':
cardData = await pokemonService.getCardByName(cardName);
break;
case 'YUGIOH':
cardData = await yugiohService.getCardByName(cardName);
break;
}
if (cardData) {
console.log(` ✅ Found: ${cardData.name} (${cardData.set_name})`);
// Add to database
const result = await databaseAPI.addCard(cardData);
if (result && result.success) {
console.log(` 💾 Added to database: ${cardData.name}`);
totalAdded++;
} else {
console.log(` ⚠️ Already exists or failed to add: ${cardData.name}`);
totalErrors++;
}
} else {
console.log(` ❌ Not found: ${cardName}`);
totalErrors++;
}
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('');
}
console.log('📊 Population Summary:');
console.log(` Total processed: ${totalProcessed}`);
console.log(` Successfully added: ${totalAdded}`);
console.log(` Errors/not found: ${totalErrors}`);
console.log('\n✨ Database population complete!');
}
// Run the script
if (require.main === module) {
populateDatabase().catch(console.error);
}
module.exports = {
populateDatabase,
mtgService,
pokemonService,
yugiohService,
databaseAPI
};

View file

@ -0,0 +1,364 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import cardDataService from '../../services/cardDataSources';
import type { Card } from '../../types';
interface CardDatabaseBrowserProps {
isOpen: boolean;
onClose: () => void;
onCardSelect?: (card: Card) => void;
}
const CardDatabaseBrowser: React.FC<CardDatabaseBrowserProps> = ({
isOpen,
onClose,
onCardSelect
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedGame, setSelectedGame] = useState<string>('');
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [showCardManager, setShowCardManager] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const queryClient = useQueryClient();
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// Search external APIs
const { data: externalCards = [], isLoading: isSearchingExternal } = useQuery({
queryKey: ['external-cards', debouncedSearch, selectedGame],
queryFn: async () => {
if (!debouncedSearch.trim()) return [];
setIsSearching(true);
try {
const cards = await cardDataService.searchCards(debouncedSearch, selectedGame || undefined);
return cards;
} finally {
setIsSearching(false);
}
},
enabled: !!debouncedSearch.trim() && isOpen,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Get random cards for discovery
const { data: randomCards = [] } = useQuery({
queryKey: ['random-cards', selectedGame],
queryFn: async () => {
return await cardDataService.getRandomCards(12, selectedGame || undefined);
},
enabled: isOpen && !debouncedSearch.trim(),
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Add card to database mutation
const addToDatabaseMutation = useMutation({
mutationFn: async (card: Card) => {
// First, try to add the card to our database
const response = await fetch('/api/cards/find-or-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('tcg-vault-token')}`,
},
body: JSON.stringify({
name: card.name,
game: card.game,
setName: card.set_name,
setCode: card.set_code,
rarity: card.rarity,
cardType: card.card_type,
manaCost: card.mana_cost,
imageUrl: card.stock_image_url,
}),
});
if (!response.ok) {
throw new Error('Failed to add card to database');
}
const result = await response.json();
return result.card;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards'] });
},
});
const handleCardClick = async (card: Card) => {
if (onCardSelect) {
onCardSelect(card);
onClose();
} else {
setSelectedCard(card);
setShowCardManager(true);
}
};
const handleAddToDatabase = async (card: Card) => {
try {
await addToDatabaseMutation.mutateAsync(card);
// Show success message
alert(`${card.name} has been added to the database!`);
} catch (error) {
console.error('Error adding card to database:', error);
alert('Failed to add card to database. Please try again.');
}
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getRarityBadgeColor = (rarity: string) => {
switch (rarity?.toLowerCase()) {
case 'common': return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
case 'uncommon': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'rare': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'super rare': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'legendary': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'mythic': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
if (!isOpen) return null;
const displayCards = debouncedSearch.trim() ? externalCards : randomCards;
const isLoading = isSearchingExternal || isSearching;
return (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl h-[90vh] overflow-hidden animate-slide-up">
<div className="p-4 pb-0">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-4"></div>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
Card Database Browser
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="relative mb-4">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
</div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search cards from external databases..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Game Filter */}
<div className="flex space-x-2 mb-4 overflow-x-auto pb-2">
<button
onClick={() => setSelectedGame('')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
!selectedGame
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
All Games
</button>
<button
onClick={() => setSelectedGame('MTG')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'MTG'
? 'bg-orange-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Magic: The Gathering
</button>
<button
onClick={() => setSelectedGame('POKEMON')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'POKEMON'
? 'bg-yellow-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Pokémon
</button>
<button
onClick={() => setSelectedGame('YUGIOH')}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'YUGIOH'
? 'bg-blue-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Yu-Gi-Oh!
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto pb-20">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
<span className="ml-3 text-surface-600 dark:text-surface-400">
Searching external databases...
</span>
</div>
) : displayCards.length === 0 ? (
<div className="text-center py-8">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-2">
{debouncedSearch.trim() ? 'No cards found' : 'Discover Cards'}
</h3>
<p className="text-surface-600 dark:text-surface-400">
{debouncedSearch.trim()
? 'Try adjusting your search terms or game filter'
: 'Search for cards to see results from external databases'
}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{displayCards.map((card) => (
<div
key={`${card.game}-${card.id}`}
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-start space-x-4">
{/* Card Image */}
<div className="w-16 h-20 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center flex-shrink-0">
{card.stock_image_url ? (
<img
src={card.stock_image_url}
alt={card.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<svg className="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
{/* Card Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-surface-900 dark:text-white truncate">
{card.name}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400">
{card.set_name} {card.card_number}
</p>
{/* Badges */}
<div className="flex flex-wrap gap-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
{card.rarity && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
)}
{card.current_price && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full text-xs font-medium">
${card.current_price}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col space-y-2">
<button
onClick={() => handleCardClick(card)}
className="px-3 py-1 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded-lg transition-colors"
>
Add to Collection
</button>
<button
onClick={() => handleAddToDatabase(card)}
disabled={addToDatabaseMutation.isPending}
className="px-3 py-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
>
{addToDatabaseMutation.isPending ? 'Adding...' : 'Add to DB'}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
{/* Card Manager Modal */}
{selectedCard && showCardManager && (
<div className="fixed inset-0 z-60">
{/* This would render the CardManager component */}
<div className="fixed inset-0 bg-black/50" onClick={() => setShowCardManager(false)}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 max-h-[80vh] overflow-y-auto">
<div className="text-center">
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-4">
Add {selectedCard.name} to your collection
</h3>
<p className="text-surface-600 dark:text-surface-400 mb-6">
This card will be added to your collection with the details you specify.
</p>
<div className="flex space-x-4">
<button
onClick={() => {
handleCardClick(selectedCard);
setShowCardManager(false);
}}
className="flex-1 bg-primary-500 hover:bg-primary-600 text-white font-medium py-3 px-6 rounded-xl transition-colors"
>
Continue
</button>
<button
onClick={() => setShowCardManager(false)}
className="flex-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 font-medium py-3 px-6 rounded-xl transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
};
export default CardDatabaseBrowser;

View file

@ -2,10 +2,11 @@ import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { tcgApi } from '../services/tcgApi'; import { tcgApi } from '../services/tcgApi';
import CardSearch from '../components/cards/CardSearch'; import CardSearch from '../components/cards/CardSearch';
import CardDatabaseBrowser from '../components/cards/CardDatabaseBrowser';
import CardManager from '../components/cards/CardManager'; import CardManager from '../components/cards/CardManager';
import CardImageDisplay from '../components/CardImageDisplay'; import CardImageDisplay from '../components/CardImageDisplay';
import GlowingCard from '../components/GlowingCard'; import GlowingCard from '../components/GlowingCard';
import type { UserCard, CardFilters } from '../types'; import type { UserCard, CardFilters, Card } from '../types';
const Cards: React.FC = () => { const Cards: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -17,6 +18,7 @@ const Cards: React.FC = () => {
}); });
const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [showCardSearch, setShowCardSearch] = useState(false); const [showCardSearch, setShowCardSearch] = useState(false);
const [showDatabaseBrowser, setShowDatabaseBrowser] = useState(false);
const [editingCard, setEditingCard] = useState<string | null>(null); const [editingCard, setEditingCard] = useState<string | null>(null);
const { data: userCards = [], isLoading, error } = useQuery({ const { data: userCards = [], isLoading, error } = useQuery({
@ -90,6 +92,7 @@ const Cards: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex space-x-3">
<button <button
onClick={() => setShowCardSearch(true)} onClick={() => setShowCardSearch(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5" className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
@ -99,6 +102,16 @@ const Cards: React.FC = () => {
</svg> </svg>
Add Cards Add Cards
</button> </button>
<button
onClick={() => setShowDatabaseBrowser(true)}
className="bg-gradient-to-r from-accent-500 to-primary-500 hover:from-accent-600 hover:to-primary-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 mr-2 inline" 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>
Browse Database
</button>
</div>
</div> </div>
{/* Search and Filter Bar */} {/* Search and Filter Bar */}
@ -443,6 +456,17 @@ const Cards: React.FC = () => {
cardId={editingCard} cardId={editingCard}
/> />
)} )}
{/* Database Browser Modal */}
<CardDatabaseBrowser
isOpen={showDatabaseBrowser}
onClose={() => setShowDatabaseBrowser(false)}
onCardSelect={(card: Card) => {
// Handle card selection from database browser
console.log('Selected card from database:', card);
setShowDatabaseBrowser(false);
}}
/>
</div> </div>
); );
}; };

View file

@ -0,0 +1,364 @@
import type { Card } from '../types';
// API Configuration
const SCRYFALL_BASE_URL = 'https://api.scryfall.com';
const POKEMON_API_BASE_URL = 'https://api.pokemontcg.io/v2';
const YUGIOH_API_BASE_URL = 'https://db.ygoprodeck.com/api/v7';
// Rate limiting utilities
class RateLimiter {
private requests: number[] = [];
private maxRequests: number;
private timeWindow: number;
constructor(maxRequests: number, timeWindow: number) {
this.maxRequests = maxRequests;
this.timeWindow = timeWindow;
}
async waitForSlot(): Promise<void> {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < this.timeWindow);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.timeWindow - (now - oldestRequest);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requests.push(now);
}
}
// Rate limiters for each API
const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second
const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second
// ============================================================================
// MAGIC: THE GATHERING (Scryfall API)
// ============================================================================
export const mtgService = {
async searchCards(query: string, page = 1): Promise<Card[]> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(
`${SCRYFALL_BASE_URL}/cards/search?q=${encodeURIComponent(query)}&page=${page}`
);
if (!response.ok) {
throw new Error(`Scryfall API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching MTG cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(
`${SCRYFALL_BASE_URL}/cards/named?fuzzy=${encodeURIComponent(name)}`
);
if (!response.ok) {
return null;
}
const card = await response.json();
return {
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
} catch (error) {
console.error('Error fetching MTG card:', error);
return null;
}
},
async getRandomCards(count = 20): Promise<Card[]> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(`${SCRYFALL_BASE_URL}/cards/random?q=game:paper&page_size=${count}`);
if (!response.ok) {
throw new Error(`Scryfall API error: ${response.status}`);
}
const data = await response.json();
const cards = Array.isArray(data) ? data : [data];
return cards.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching random MTG cards:', error);
return [];
}
}
};
// ============================================================================
// POKÉMON TCG API
// ============================================================================
export const pokemonService = {
async searchCards(query: string, page = 1): Promise<Card[]> {
await pokemonLimiter.waitForSlot();
try {
const response = await fetch(
`${POKEMON_API_BASE_URL}/cards?q=name:${encodeURIComponent(query)}&page=${page}&pageSize=20`
);
if (!response.ok) {
throw new Error(`Pokémon API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set.name,
set_code: card.set.id,
card_number: card.number,
rarity: card.rarity,
game: 'POKEMON',
mana_cost: card.convertedRetreatCost?.toString(),
cmc: card.convertedRetreatCost,
card_type: card.supertype,
colors: card.types || [],
oracle_text: card.attacks?.map((attack: any) => `${attack.name}: ${attack.text}`).join('\n'),
power: card.hp,
toughness: null,
image_url: card.images.small,
stock_image_url: card.images.large,
current_price: card.cardmarket?.prices?.averageSellPrice || null,
market_price: card.cardmarket?.prices?.lowPrice || null,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching Pokémon cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
const cards = await this.searchCards(name, 1);
return cards.length > 0 ? cards[0] : null;
}
};
// ============================================================================
// YU-GI-OH! API
// ============================================================================
export const yugiohService = {
async searchCards(query: string): Promise<Card[]> {
try {
const response = await fetch(
`${YUGIOH_API_BASE_URL}/cardinfo.php?fname=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error(`Yu-Gi-Oh! API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set_code,
card_number: card.num,
rarity: card.rarity,
game: 'YUGIOH',
mana_cost: card.level?.toString(),
cmc: card.level,
card_type: card.type,
colors: [card.attribute],
oracle_text: card.desc,
power: card.atk?.toString(),
toughness: card.def?.toString(),
image_url: card.card_images?.[0]?.image_url,
stock_image_url: card.card_images?.[0]?.image_url_small,
current_price: card.card_prices?.[0]?.amazon_price ? parseFloat(card.card_prices[0].amazon_price) : null,
market_price: card.card_prices?.[0]?.cardmarket_price ? parseFloat(card.card_prices[0].cardmarket_price) : null,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching Yu-Gi-Oh! cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
const cards = await this.searchCards(name);
return cards.length > 0 ? cards[0] : null;
}
};
// ============================================================================
// UNIFIED CARD SEARCH
// ============================================================================
export const cardDataService = {
async searchCards(query: string, game?: string): Promise<Card[]> {
const results: Card[] = [];
try {
if (!game || game === 'MTG') {
const mtgCards = await mtgService.searchCards(query);
results.push(...mtgCards);
}
if (!game || game === 'POKEMON') {
const pokemonCards = await pokemonService.searchCards(query);
results.push(...pokemonCards);
}
if (!game || game === 'YUGIOH') {
const yugiohCards = await yugiohService.searchCards(query);
results.push(...yugiohCards);
}
// Remove duplicates and sort by relevance
const uniqueCards = results.filter((card, index, self) =>
index === self.findIndex(c => c.name === card.name && c.game === card.game)
);
return uniqueCards;
} catch (error) {
console.error('Error in unified card search:', error);
return [];
}
},
async getCardByName(name: string, game?: string): Promise<Card | null> {
try {
if (game === 'MTG') {
return await mtgService.getCardByName(name);
}
if (game === 'POKEMON') {
return await pokemonService.getCardByName(name);
}
if (game === 'YUGIOH') {
return await yugiohService.getCardByName(name);
}
// Try all games if no specific game is specified
const mtgCard = await mtgService.getCardByName(name);
if (mtgCard) return mtgCard;
const pokemonCard = await pokemonService.getCardByName(name);
if (pokemonCard) return pokemonCard;
const yugiohCard = await yugiohService.getCardByName(name);
if (yugiohCard) return yugiohCard;
return null;
} catch (error) {
console.error('Error in unified card search by name:', error);
return null;
}
},
async getRandomCards(count = 20, game?: string): Promise<Card[]> {
const results: Card[] = [];
try {
if (!game || game === 'MTG') {
const mtgCards = await mtgService.getRandomCards(Math.ceil(count / 3));
results.push(...mtgCards);
}
// Note: Pokémon and Yu-Gi-Oh! APIs don't have random card endpoints
// You could implement random selection from popular cards lists
return results.slice(0, count);
} catch (error) {
console.error('Error getting random cards:', error);
return [];
}
}
};
export default cardDataService;