deckhearth/src/components/cards/CardDatabaseBrowser.tsx

607 lines
No EOL
29 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 [viewMode, setViewMode] = useState<'cards' | 'table'>('cards');
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);
console.log(`Found ${cards.length} cards for search "${debouncedSearch}" in game "${selectedGame}"`);
return cards;
} catch (error) {
console.error('Error searching external cards:', error);
return [];
} finally {
setIsSearching(false);
}
},
enabled: !!debouncedSearch.trim() && isOpen,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
});
// Get random cards for discovery
const { data: randomCards = [] } = useQuery({
queryKey: ['random-cards', selectedGame],
queryFn: async () => {
try {
const cards = await cardDataService.getRandomCards(20, selectedGame || undefined);
console.log(`Found ${cards.length} random cards for game "${selectedGame}"`);
return cards;
} catch (error) {
console.error('Error fetching random cards:', error);
return [];
}
},
enabled: isOpen && !debouncedSearch.trim(),
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
});
// 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;
// Debug logging
console.log('CardDatabaseBrowser state:', {
isOpen,
searchTerm,
debouncedSearch,
selectedGame,
externalCards: externalCards.length,
randomCards: randomCards.length,
displayCards: displayCards.length,
isLoading
});
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"
onClick={(e) => e.stopPropagation()}
>
<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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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)}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('LORCANA');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'LORCANA'
? 'bg-purple-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Disney Lorcana
</button>
</div>
{/* View Toggle */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">View:</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setViewMode('cards');
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewMode === 'cards'
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}
>
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Cards
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setViewMode('table');
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewMode === 'table'
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}
>
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Table
</button>
</div>
{displayCards.length > 0 && (
<span className="text-sm text-surface-600 dark:text-surface-400">
{displayCards.length} card{displayCards.length !== 1 ? 's' : ''}
</span>
)}
</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>
) : (
<>
{/* Card View */}
{viewMode === 'cards' && (
<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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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>
)}
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-50 dark:bg-surface-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Card
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Set
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Game
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Rarity
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-200 dark:divide-surface-700">
{displayCards.map((card) => (
<tr key={`${card.game}-${card.id}`} className="hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center space-x-3">
<div className="w-10 h-12 bg-surface-200 dark:bg-surface-700 rounded 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"
/>
) : (
<svg className="w-4 h-4 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>
<div className="min-w-0">
<div className="text-sm font-medium text-surface-900 dark:text-white truncate">
{card.name}
</div>
<div className="text-xs text-surface-500 dark:text-surface-400">
#{card.card_number}
</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-surface-900 dark:text-white">
{card.set_name}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
</td>
<td className="px-4 py-3">
{card.rarity && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-surface-900 dark:text-white">
{card.current_price ? `$${card.current_price}` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex space-x-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCardClick(card);
}}
className="px-2 py-1 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded transition-colors"
>
Add
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToDatabase(card);
}}
disabled={addToDatabaseMutation.isPending}
className="px-2 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 transition-colors disabled:opacity-50"
>
DB
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</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;