diff --git a/api/admin/load-cards.js b/api/admin/load-cards.js
new file mode 100644
index 0000000..a9077e1
--- /dev/null
+++ b/api/admin/load-cards.js
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/api/cards/search.js b/api/cards/search.js
new file mode 100644
index 0000000..679648a
--- /dev/null
+++ b/api/cards/search.js
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/components/admin/AdminPanel.tsx b/src/components/admin/AdminPanel.tsx
index 007ad15..fc6ceb7 100644
--- a/src/components/admin/AdminPanel.tsx
+++ b/src/components/admin/AdminPanel.tsx
@@ -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