Implement database-based card loading system - add card loader API, database search, and admin interface
This commit is contained in:
parent
ee67b05e28
commit
7057417a89
5 changed files with 756 additions and 34 deletions
370
api/admin/load-cards.js
Normal file
370
api/admin/load-cards.js
Normal 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
158
api/cards/search.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { Navigate } from 'react-router-dom';
|
||||||
import UserManagement from './UserManagement';
|
import UserManagement from './UserManagement';
|
||||||
import CardManagement from './CardManagement';
|
import CardManagement from './CardManagement';
|
||||||
import SystemStats from './SystemStats';
|
import SystemStats from './SystemStats';
|
||||||
|
import CardLoader from './CardLoader';
|
||||||
|
|
||||||
type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings';
|
type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings';
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ const AdminPanel: React.FC = () => {
|
||||||
case 'users':
|
case 'users':
|
||||||
return <UserManagement />;
|
return <UserManagement />;
|
||||||
case 'cards':
|
case 'cards':
|
||||||
return <CardManagement />;
|
return <CardLoader />;
|
||||||
case 'decks':
|
case 'decks':
|
||||||
return <div className="p-6">Deck Management - Coming Soon</div>;
|
return <div className="p-6">Deck Management - Coming Soon</div>;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
|
|
||||||
185
src/components/admin/CardLoader.tsx
Normal file
185
src/components/admin/CardLoader.tsx
Normal 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;
|
||||||
|
|
@ -744,48 +744,56 @@ export const lorcanaService = {
|
||||||
|
|
||||||
export const cardDataService = {
|
export const cardDataService = {
|
||||||
async searchCards(query: string, game?: string): Promise<Card[]> {
|
async searchCards(query: string, game?: string): Promise<Card[]> {
|
||||||
const results: Card[] = [];
|
console.log(`🔍 Database search: "${query}" for game: "${game}"`);
|
||||||
|
|
||||||
console.log(`🔍 Unified search: "${query}" for game: "${game}"`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!game || game === 'MTG') {
|
const params = new URLSearchParams({
|
||||||
console.log('🔍 Searching MTG...');
|
q: query,
|
||||||
const mtgCards = await mtgService.searchCards(query);
|
limit: '50'
|
||||||
console.log(`✅ Found ${mtgCards.length} MTG cards`);
|
});
|
||||||
results.push(...mtgCards);
|
|
||||||
|
if (game && game !== 'ALL') {
|
||||||
|
params.append('game', game);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!game || game === 'POKEMON') {
|
const response = await fetch(`https://tcg-vault.vercel.app/api/cards/search?${params}`);
|
||||||
console.log('🔍 Searching Pokémon...');
|
|
||||||
const pokemonCards = await pokemonService.searchCards(query);
|
if (!response.ok) {
|
||||||
console.log(`✅ Found ${pokemonCards.length} Pokémon cards`);
|
throw new Error(`Database search failed: ${response.status}`);
|
||||||
results.push(...pokemonCards);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!game || game === 'YUGIOH') {
|
const data = await response.json();
|
||||||
console.log('🔍 Searching Yu-Gi-Oh!...');
|
|
||||||
const yugiohCards = await yugiohService.searchCards(query);
|
if (data.success && data.data) {
|
||||||
console.log(`✅ Found ${yugiohCards.length} Yu-Gi-Oh! cards`);
|
console.log(`✅ Found ${data.data.length} cards from database`);
|
||||||
results.push(...yugiohCards);
|
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') {
|
return [];
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in unified card search:', error);
|
console.error('Error in database card search:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue