Implement database-based card loading system - add card loader API, database search, and admin interface

This commit is contained in:
Randall Stillwell 2025-07-23 06:50:44 -05:00
parent ee67b05e28
commit 7057417a89
5 changed files with 756 additions and 34 deletions

370
api/admin/load-cards.js Normal file
View file

@ -0,0 +1,370 @@
import { NextResponse } from 'next/server';
import { sql } from '@vercel/postgres';
import { verifyToken } from '../setup-auth.js';
// Rate limiting for external APIs
const rateLimiters = {
mtg: { lastCall: 0, minInterval: 50 }, // 50ms between calls
pokemon: { lastCall: 0, minInterval: 100 }, // 100ms between calls
lorcana: { lastCall: 0, minInterval: 100 } // 100ms between calls
};
async function waitForRateLimit(api) {
const now = Date.now();
const limiter = rateLimiters[api];
const timeSinceLastCall = now - limiter.lastCall;
if (timeSinceLastCall < limiter.minInterval) {
await new Promise(resolve =>
setTimeout(resolve, limiter.minInterval - timeSinceLastCall)
);
}
limiter.lastCall = Date.now();
}
// Load MTG cards from Scryfall
async function loadMTGCards() {
console.log('🃏 Loading MTG cards from Scryfall...');
try {
// Get total count first
const countResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper');
const countData = await countResponse.json();
const totalCards = countData.total_cards;
console.log(`📊 Found ${totalCards} MTG cards to load`);
let loadedCount = 0;
let page = 1;
while (loadedCount < Math.min(totalCards, 1000)) { // Limit to 1000 for now
await waitForRateLimit('mtg');
const response = await fetch(`https://api.scryfall.com/cards/search?q=game:paper&page=${page}`);
const data = await response.json();
if (!data.data || data.data.length === 0) break;
for (const card of data.data) {
try {
await sql.query(`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game, mana_cost, cmc,
card_type, colors, oracle_text, power, toughness, image_url,
stock_image_url, current_price, market_price, scryfall_id, verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (scryfall_id) DO NOTHING
`, [
card.name,
card.set_name,
card.set,
card.collector_number,
card.rarity,
'MTG',
card.mana_cost,
card.cmc,
card.type_line,
JSON.stringify(card.colors),
card.oracle_text,
card.power,
card.toughness,
card.image_uris?.normal || card.image_uris?.small,
card.image_uris?.small,
card.prices?.usd ? parseFloat(card.prices.usd) : null,
card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
card.id,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading MTG card ${card.name}:`, error);
}
}
page++;
console.log(`✅ Loaded ${loadedCount} MTG cards so far...`);
}
console.log(`🎉 Successfully loaded ${loadedCount} MTG cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading MTG cards:', error);
return 0;
}
}
// Load Pokémon cards from Pokémon TCG API
async function loadPokemonCards() {
console.log('⚡ Loading Pokémon cards from Pokémon TCG API...');
try {
let loadedCount = 0;
let page = 1;
const pageSize = 250; // Max allowed by API
while (loadedCount < 1000) { // Limit to 1000 for now
await waitForRateLimit('pokemon');
const response = await fetch(`https://api.pokemontcg.io/v2/cards?page=${page}&pageSize=${pageSize}`, {
headers: {
'X-Api-Key': process.env.POKEMON_API_KEY || ''
}
});
const data = await response.json();
if (!data.data || data.data.length === 0) break;
for (const card of data.data) {
try {
await sql.query(`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game, mana_cost, cmc,
card_type, colors, oracle_text, power, toughness, image_url,
stock_image_url, current_price, market_price, tcg_player_id, verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (tcg_player_id) DO NOTHING
`, [
card.name,
card.set.name,
card.set.id,
card.number,
card.rarity,
'POKEMON',
card.convertedRetreatCost?.toString() || null,
card.convertedRetreatCost,
card.supertype + (card.subtypes ? ' - ' + card.subtypes.join(', ') : ''),
JSON.stringify(card.types || []),
card.flavorText || card.rules?.join(' ') || '',
card.attacks?.[0]?.damage || null,
card.hp || null,
card.images?.large,
card.images?.small,
card.cardmarket?.prices?.averageSellPrice ? parseFloat(card.cardmarket.prices.averageSellPrice) : null,
card.cardmarket?.prices?.lowPrice ? parseFloat(card.cardmarket.prices.lowPrice) : null,
card.id,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading Pokémon card ${card.name}:`, error);
}
}
page++;
console.log(`✅ Loaded ${loadedCount} Pokémon cards so far...`);
}
console.log(`🎉 Successfully loaded ${loadedCount} Pokémon cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading Pokémon cards:', error);
return 0;
}
}
// Load Lorcana cards from multiple sources
async function loadLorcanaCards() {
console.log('🏰 Loading Lorcana cards from multiple sources...');
try {
let loadedCount = 0;
// Try Lorcana API first
try {
await waitForRateLimit('lorcana');
const response = await fetch('https://api.lorcana-api.com/cards/fetch?pagesize=1000');
const data = await response.json();
if (data.cards && data.cards.length > 0) {
console.log(`📊 Found ${data.cards.length} cards from Lorcana API`);
for (const card of data.cards) {
try {
await sql.query(`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game, mana_cost, cmc,
card_type, colors, oracle_text, power, toughness, image_url,
stock_image_url, current_price, market_price, verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (name, set_code, card_number) DO NOTHING
`, [
card.name || card.card_name || card.title || '',
card.set?.name || card.set_name || '',
card.set?.code || card.set_code || '',
card.number || card.card_number || card.card_num || '',
card.rarity || card.rarity_name || '',
'LORCANA',
card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
card.cost || card.cmc || card.ink_cost || 0,
card.type || card.card_type || card.type_name || '',
JSON.stringify(card.colors || card.ink || []),
card.text || card.oracle_text || card.description || card.effect || '',
card.strength?.toString() || card.power || card.attack?.toString() || '',
card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
card.image_url || card.images?.small || card.images?.png || card.image || '',
card.image_url || card.images?.small || card.images?.png || card.image || '',
card.price?.market || card.current_price || null,
card.price?.low || card.market_price || null,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading Lorcana card ${card.name}:`, error);
}
}
}
} catch (error) {
console.log('❌ Lorcana API failed, trying Lorcast...');
}
// Try Lorcast API as fallback
if (loadedCount === 0) {
try {
await waitForRateLimit('lorcana');
const response = await fetch('https://api.lorcast.com/v0/cards');
const data = await response.json();
if (data.cards && data.cards.length > 0) {
console.log(`📊 Found ${data.cards.length} cards from Lorcast API`);
for (const card of data.cards) {
try {
await sql.query(`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game, mana_cost, cmc,
card_type, colors, oracle_text, power, toughness, image_url,
stock_image_url, current_price, market_price, verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (name, set_code, card_number) DO NOTHING
`, [
card.name || card.card_name || card.title || '',
card.set?.name || card.set_name || '',
card.set?.code || card.set_code || '',
card.number || card.card_number || card.card_num || '',
card.rarity || card.rarity_name || '',
'LORCANA',
card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
card.cost || card.cmc || card.ink_cost || 0,
card.type || card.card_type || card.type_name || '',
JSON.stringify(card.colors || card.ink || []),
card.text || card.oracle_text || card.description || card.effect || '',
card.strength?.toString() || card.power || card.attack?.toString() || '',
card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
card.image_url || card.images?.small || card.images?.png || card.image || '',
card.image_url || card.images?.small || card.images?.png || card.image || '',
card.price?.market || card.current_price || null,
card.price?.low || card.market_price || null,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading Lorcana card ${card.name}:`, error);
}
}
}
} catch (error) {
console.error('❌ Lorcast API also failed:', error);
}
}
console.log(`🎉 Successfully loaded ${loadedCount} Lorcana cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading Lorcana cards:', error);
return 0;
}
}
// POST /api/admin/load-cards - Load cards from external APIs
export async function POST(request) {
try {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check if user is admin (you can implement your own admin check)
const { searchParams } = new URL(request.url);
const game = searchParams.get('game'); // 'MTG', 'POKEMON', 'LORCANA', or 'ALL'
console.log(`🚀 Starting card loading process for game: ${game}`);
let results = {};
if (game === 'MTG' || game === 'ALL') {
results.mtg = await loadMTGCards();
}
if (game === 'POKEMON' || game === 'ALL') {
results.pokemon = await loadPokemonCards();
}
if (game === 'LORCANA' || game === 'ALL') {
results.lorcana = await loadLorcanaCards();
}
const totalLoaded = Object.values(results).reduce((sum, count) => sum + count, 0);
console.log(`🎉 Card loading completed! Total loaded: ${totalLoaded}`);
return NextResponse.json({
success: true,
message: `Successfully loaded ${totalLoaded} cards`,
results
});
} catch (error) {
console.error('❌ Error in card loading API:', error);
return NextResponse.json(
{ error: 'Failed to load cards', details: error.message },
{ status: 500 }
);
}
}
// GET /api/admin/load-cards - Get loading status
export async function GET(request) {
try {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get card counts from database
const result = await sql.query(`
SELECT
game,
COUNT(*) as count
FROM cards
GROUP BY game
`);
const counts = {};
result.rows.forEach(row => {
counts[row.game] = parseInt(row.count);
});
return NextResponse.json({
success: true,
counts,
total: Object.values(counts).reduce((sum, count) => sum + count, 0)
});
} catch (error) {
console.error('❌ Error getting card counts:', error);
return NextResponse.json(
{ error: 'Failed to get card counts', details: error.message },
{ status: 500 }
);
}
}

158
api/cards/search.js Normal file
View file

@ -0,0 +1,158 @@
import { NextResponse } from 'next/server';
import { sql } from '@vercel/postgres';
// GET /api/cards/search - Search cards from database
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || searchParams.get('search') || '';
const game = searchParams.get('game');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const offset = (page - 1) * limit;
console.log(`🔍 Searching cards: "${query}" game: "${game}" page: ${page}`);
// Build the SQL query
let sqlQuery = `
SELECT
id,
name,
set_name,
set_code,
card_number,
rarity,
game,
mana_cost,
cmc,
card_type,
colors,
oracle_text,
power,
toughness,
image_url,
stock_image_url,
current_price,
market_price,
verified,
created_at,
updated_at
FROM cards
WHERE 1=1
`;
const params = [];
let paramIndex = 1;
// Add search filter
if (query.trim()) {
sqlQuery += ` AND (
name ILIKE $${paramIndex} OR
oracle_text ILIKE $${paramIndex} OR
card_type ILIKE $${paramIndex} OR
set_name ILIKE $${paramIndex}
)`;
params.push(`%${query}%`);
paramIndex++;
}
// Add game filter
if (game && game !== 'ALL') {
sqlQuery += ` AND game = $${paramIndex}`;
params.push(game);
paramIndex++;
}
// Add ordering and pagination
sqlQuery += ` ORDER BY name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
params.push(limit, offset);
console.log(`📝 SQL Query: ${sqlQuery}`);
console.log(`📝 Parameters:`, params);
// Execute the query
const result = await sql.query(sqlQuery, params);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*) as total
FROM cards
WHERE 1=1
`;
const countParams = [];
let countParamIndex = 1;
if (query.trim()) {
countQuery += ` AND (
name ILIKE $${countParamIndex} OR
oracle_text ILIKE $${countParamIndex} OR
card_type ILIKE $${countParamIndex} OR
set_name ILIKE $${countParamIndex}
)`;
countParams.push(`%${query}%`);
countParamIndex++;
}
if (game && game !== 'ALL') {
countQuery += ` AND game = $${countParamIndex}`;
countParams.push(game);
countParamIndex++;
}
const countResult = await sql.query(countQuery, countParams);
const total = parseInt(countResult.rows[0].total);
// Transform the results
const cards = result.rows.map(row => ({
id: row.id,
name: row.name,
set_name: row.set_name,
set_code: row.set_code,
card_number: row.card_number,
rarity: row.rarity,
game: row.game,
mana_cost: row.mana_cost,
cmc: row.cmc,
card_type: row.card_type,
colors: row.colors ? JSON.parse(row.colors) : [],
oracle_text: row.oracle_text,
power: row.power,
toughness: row.toughness,
image_url: row.image_url,
stock_image_url: row.stock_image_url,
current_price: row.current_price,
market_price: row.market_price,
verified: row.verified,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
console.log(`✅ Found ${cards.length} cards (total: ${total})`);
return NextResponse.json({
success: true,
data: cards,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
},
search: {
query,
game,
results: cards.length
}
});
} catch (error) {
console.error('❌ Error searching cards:', error);
return NextResponse.json(
{ error: 'Failed to search cards', details: error.message },
{ status: 500 }
);
}
}

View file

@ -4,6 +4,7 @@ import { Navigate } from 'react-router-dom';
import UserManagement from './UserManagement';
import CardManagement from './CardManagement';
import SystemStats from './SystemStats';
import CardLoader from './CardLoader';
type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings';
@ -31,7 +32,7 @@ const AdminPanel: React.FC = () => {
case 'users':
return <UserManagement />;
case 'cards':
return <CardManagement />;
return <CardLoader />;
case 'decks':
return <div className="p-6">Deck Management - Coming Soon</div>;
case 'settings':

View file

@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
interface CardCounts {
MTG?: number;
POKEMON?: number;
LORCANA?: number;
total?: number;
}
interface LoadingResults {
mtg?: number;
pokemon?: number;
lorcana?: number;
}
const CardLoader: React.FC = () => {
const { user, token } = useAuth();
const [cardCounts, setCardCounts] = useState<CardCounts>({});
const [loading, setLoading] = useState(false);
const [loadingResults, setLoadingResults] = useState<LoadingResults>({});
const [selectedGame, setSelectedGame] = useState<string>('ALL');
const [message, setMessage] = useState<string>('');
// Fetch current card counts
const fetchCardCounts = async () => {
try {
const response = await fetch('https://tcg-vault.vercel.app/api/admin/load-cards', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setCardCounts(data.counts || {});
}
} catch (error) {
console.error('Error fetching card counts:', error);
}
};
useEffect(() => {
fetchCardCounts();
}, []);
// Load cards from external APIs
const loadCards = async (game: string) => {
setLoading(true);
setMessage(`Loading ${game} cards...`);
setLoadingResults({});
try {
const response = await fetch(`https://tcg-vault.vercel.app/api/admin/load-cards?game=${game}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setLoadingResults(data.results || {});
setMessage(`Successfully loaded cards! ${data.message}`);
// Refresh card counts
setTimeout(() => {
fetchCardCounts();
}, 1000);
} else {
const errorData = await response.json();
setMessage(`Error loading cards: ${errorData.error}`);
}
} catch (error) {
console.error('Error loading cards:', error);
setMessage('Error loading cards. Please try again.');
} finally {
setLoading(false);
}
};
const handleLoadCards = () => {
loadCards(selectedGame);
};
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">Card Database Loader</h2>
{/* Current Card Counts */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Current Database Status</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{cardCounts.MTG || 0}</div>
<div className="text-sm text-blue-500">MTG Cards</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{cardCounts.POKEMON || 0}</div>
<div className="text-sm text-yellow-500">Pokémon Cards</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{cardCounts.LORCANA || 0}</div>
<div className="text-sm text-purple-500">Lorcana Cards</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{cardCounts.total || 0}</div>
<div className="text-sm text-green-500">Total Cards</div>
</div>
</div>
</div>
{/* Load Cards Section */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Load Cards from External APIs</h3>
<div className="flex flex-col sm:flex-row gap-4 mb-4">
<select
value={selectedGame}
onChange={(e) => setSelectedGame(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="ALL">All Games (MTG, Pokémon, Lorcana)</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
</select>
<button
onClick={handleLoadCards}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Loading...' : 'Load Cards'}
</button>
</div>
{/* Loading Results */}
{Object.keys(loadingResults).length > 0 && (
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-semibold text-gray-700 mb-2">Loading Results:</h4>
<div className="space-y-2">
{loadingResults.mtg !== undefined && (
<div className="text-blue-600">MTG: {loadingResults.mtg} cards loaded</div>
)}
{loadingResults.pokemon !== undefined && (
<div className="text-yellow-600">Pokémon: {loadingResults.pokemon} cards loaded</div>
)}
{loadingResults.lorcana !== undefined && (
<div className="text-purple-600">Lorcana: {loadingResults.lorcana} cards loaded</div>
)}
</div>
</div>
)}
{/* Message */}
{message && (
<div className={`mt-4 p-3 rounded-lg ${
message.includes('Error')
? 'bg-red-50 text-red-700 border border-red-200'
: 'bg-green-50 text-green-700 border border-green-200'
}`}>
{message}
</div>
)}
</div>
{/* Instructions */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-2">Instructions</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> This will load cards from external APIs into your database</li>
<li> MTG cards come from Scryfall API</li>
<li> Pokémon cards come from Pokémon TCG API</li>
<li> Lorcana cards come from Lorcana API and Lorcast API</li>
<li> Loading may take several minutes for large datasets</li>
<li> Cards are deduplicated automatically</li>
</ul>
</div>
</div>
);
};
export default CardLoader;

View file

@ -744,48 +744,56 @@ export const lorcanaService = {
export const cardDataService = {
async searchCards(query: string, game?: string): Promise<Card[]> {
const results: Card[] = [];
console.log(`🔍 Unified search: "${query}" for game: "${game}"`);
console.log(`🔍 Database search: "${query}" for game: "${game}"`);
try {
if (!game || game === 'MTG') {
console.log('🔍 Searching MTG...');
const mtgCards = await mtgService.searchCards(query);
console.log(`✅ Found ${mtgCards.length} MTG cards`);
results.push(...mtgCards);
const params = new URLSearchParams({
q: query,
limit: '50'
});
if (game && game !== 'ALL') {
params.append('game', game);
}
if (!game || game === 'POKEMON') {
console.log('🔍 Searching Pokémon...');
const pokemonCards = await pokemonService.searchCards(query);
console.log(`✅ Found ${pokemonCards.length} Pokémon cards`);
results.push(...pokemonCards);
const response = await fetch(`https://tcg-vault.vercel.app/api/cards/search?${params}`);
if (!response.ok) {
throw new Error(`Database search failed: ${response.status}`);
}
if (!game || game === 'YUGIOH') {
console.log('🔍 Searching Yu-Gi-Oh!...');
const yugiohCards = await yugiohService.searchCards(query);
console.log(`✅ Found ${yugiohCards.length} Yu-Gi-Oh! cards`);
results.push(...yugiohCards);
const data = await response.json();
if (data.success && data.data) {
console.log(`✅ Found ${data.data.length} cards from database`);
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.card_number,
rarity: card.rarity,
game: card.game,
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.card_type,
colors: card.colors || [],
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_url,
stock_image_url: card.stock_image_url,
current_price: card.current_price,
market_price: card.market_price,
verified: card.verified,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
}));
}
if (!game || game === 'LORCANA') {
console.log('🔍 Searching Lorcana...');
const lorcanaCards = await lorcanaService.searchCards(query);
console.log(`✅ Found ${lorcanaCards.length} Lorcana cards`);
results.push(...lorcanaCards);
}
// 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)
);
console.log(`🎯 Total results: ${results.length}, Unique: ${uniqueCards.length}`);
return uniqueCards;
return [];
} catch (error) {
console.error('Error in unified card search:', error);
console.error('Error in database card search:', error);
return [];
}
},