import React, { useState, useEffect } from 'react'; import { Plus, Minus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw, X } from 'lucide-react'; import { Card, Deck } from '../types'; import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; import { validateDeck } from '../utils/deckValidation'; import MagicCard from './MagicCard'; import { ManaCost, ManaSymbol } from './ManaCost'; interface DeckManagerProps { initialDeck?: Deck; onSave?: () => void; } // const calculateManaCurve = (cards: { card; quantity: number }[]) => { // const manaValues = cards.map(({ card }) => { // if (!card.mana_cost) return 0; // // Basic heuristic: count mana symbols // return (card.mana_cost.match(/\{WUBRG0-9]\}/g) || []).length; // }); // const averageManaValue = manaValues.reduce((a, b) => a + b, 0) / manaValues.length; // return averageManaValue; // }; const suggestLandCountAndDistribution = ( cards: { card; quantity: number }[], format: string, commanderColors: string[] = [] ) => { const formatRules = { standard: { minCards: 60 }, modern: { minCards: 60 }, commander: { minCards: 100 }, legacy: { minCards: 60 }, vintage: { minCards: 60 }, pauper: { minCards: 60 }, }; const { minCards } = formatRules[format as keyof typeof formatRules] || formatRules.standard; const deckSize = cards.reduce((acc, { quantity }) => acc + quantity, 0); const landsToAdd = Math.max(0, minCards - deckSize); const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 }; let totalColorSymbols = 0; cards.forEach(({ card, quantity }) => { if (card.mana_cost) { const wMatches = (card.mana_cost.match(/\{W\}/g) || []).length; const uMatches = (card.mana_cost.match(/\{U\}/g) || []).length; const bMatches = (card.mana_cost.match(/\{B\}/g) || []).length; const rMatches = (card.mana_cost.match(/\{R\}/g) || []).length; const gMatches = (card.mana_cost.match(/\{G\}/g) || []).length; colorCounts.W += wMatches * quantity; colorCounts.U += uMatches * quantity; colorCounts.B += bMatches * quantity; colorCounts.R += rMatches * quantity; colorCounts.G += gMatches * quantity; totalColorSymbols += (wMatches + uMatches + bMatches + rMatches + gMatches) * quantity; } }); // For commander, filter out colors not in commander's color identity if (format === 'commander' && commanderColors.length > 0) { for (const color in colorCounts) { if (!commanderColors.includes(color)) { totalColorSymbols -= colorCounts[color as keyof typeof colorCounts]; colorCounts[color as keyof typeof colorCounts] = 0; } } } const landDistribution: { [key: string]: number } = {}; for (const color in colorCounts) { const proportion = totalColorSymbols > 0 ? colorCounts[color as keyof typeof colorCounts] / totalColorSymbols : 0; landDistribution[color] = Math.round(landsToAdd * proportion); } const totalDistributed = Object.values(landDistribution).reduce( (acc, count) => acc + count, 0 ); if (totalDistributed > landsToAdd) { // Find the color with the most lands let maxColor = ''; let maxCount = 0; for (const color in landDistribution) { if (landDistribution[color] > maxCount) { maxColor = color; maxCount = landDistribution[color]; } } // Reduce the land count of that color landDistribution[maxColor] = maxCount - 1; } return { landCount: landsToAdd, landDistribution }; }; // Get commander color identity const getCommanderColors = (commander: Card | null): string[] => { if (!commander) return []; return commander.colors || []; }; // Check if a card's colors are valid for the commander const isCardValidForCommander = (card: Card, commanderColors: string[]): boolean => { if (commanderColors.length === 0) return true; // No commander restriction const cardColors = card.colors || []; // Every color in the card must be in the commander's colors return cardColors.every(color => commanderColors.includes(color)); }; export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) { const [currentDeckId, setCurrentDeckId] = useState(initialDeck?.id || null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [selectedCards, setSelectedCards] = useState<{ card: Card; quantity: number; }[]>(initialDeck?.cards || []); const [deckName, setDeckName] = useState(initialDeck?.name || ''); const [deckFormat, setDeckFormat] = useState(initialDeck?.format || 'standard'); const [commander, setCommander] = useState( initialDeck?.cards.find(card => card.is_commander )?.card || null ); const { user } = useAuth(); const [isImporting, setIsImporting] = useState(false); const [isSaving, setIsSaving] = useState(false); const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null); // Collection management state const [userCollection, setUserCollection] = useState>(new Map()); const [isLoadingCollection, setIsLoadingCollection] = useState(true); const [addingCardId, setAddingCardId] = useState(null); const [isAddingAll, setIsAddingAll] = useState(false); const [cardFaceIndex, setCardFaceIndex] = useState>(new Map()); const [hoveredCard, setHoveredCard] = useState(null); const [selectedCard, setSelectedCard] = useState(null); // Load user collection on component mount useEffect(() => { const loadUserCollection = async () => { if (!user) return; try { setIsLoadingCollection(true); const collection = await getUserCollection(user.id); setUserCollection(collection); } catch (error) { console.error('Error loading user collection:', error); setSnackbar({ message: 'Failed to load collection', type: 'error' }); } finally { setIsLoadingCollection(false); } }; loadUserCollection(); }, [user]); // Helper functions for double-faced cards const isDoubleFaced = (card: Card) => { const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card']; return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout); }; const getCurrentFaceIndex = (cardId: string) => { return cardFaceIndex.get(cardId) || 0; }; const toggleCardFace = (cardId: string, totalFaces: number) => { setCardFaceIndex(prev => { const newMap = new Map(prev); const currentIndex = prev.get(cardId) || 0; const nextIndex = (currentIndex + 1) % totalFaces; newMap.set(cardId, nextIndex); return newMap; }); }; const getCardImageUri = (card: Card, faceIndex: number = 0) => { if (isDoubleFaced(card) && card.card_faces) { return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small; } return card.image_uris?.normal || card.image_uris?.small || card.card_faces?.[0]?.image_uris?.normal; }; const getCardLargeImageUri = (card: Card, faceIndex: number = 0) => { if (isDoubleFaced(card) && card.card_faces) { return card.card_faces[faceIndex]?.image_uris?.large || card.card_faces[faceIndex]?.image_uris?.normal; } return card.image_uris?.large || card.image_uris?.normal; }; // Helper function to check if a card is in the collection const isCardInCollection = (cardId: string, requiredQuantity: number = 1): boolean => { const ownedQuantity = userCollection.get(cardId) || 0; return ownedQuantity >= requiredQuantity; }; // Helper function to get missing cards const getMissingCards = () => { return selectedCards.filter(({ card, quantity }) => { return !isCardInCollection(card.id, quantity); }); }; // Add single card to collection const handleAddCardToCollection = async (cardId: string, quantity: number) => { if (!user) return; try { setAddingCardId(cardId); await addCardToCollection(user.id, cardId, quantity); // Update local collection state setUserCollection(prev => { const newMap = new Map(prev); const currentQty = newMap.get(cardId) || 0; newMap.set(cardId, currentQty + quantity); return newMap; }); setSnackbar({ message: 'Card added to collection!', type: 'success' }); } catch (error) { console.error('Error adding card to collection:', error); setSnackbar({ message: 'Failed to add card to collection', type: 'error' }); } finally { setAddingCardId(null); setTimeout(() => setSnackbar(null), 3000); } }; // Add all missing cards to collection const handleAddAllMissingCards = async () => { if (!user) return; const missingCards = getMissingCards(); if (missingCards.length === 0) { setSnackbar({ message: 'All cards are already in your collection!', type: 'success' }); setTimeout(() => setSnackbar(null), 3000); return; } try { setIsAddingAll(true); const cardsToAdd = missingCards.map(({ card, quantity }) => { const ownedQuantity = userCollection.get(card.id) || 0; const neededQuantity = Math.max(0, quantity - ownedQuantity); return { cardId: card.id, quantity: neededQuantity, }; }).filter(c => c.quantity > 0); await addMultipleCardsToCollection(user.id, cardsToAdd); // Update local collection state setUserCollection(prev => { const newMap = new Map(prev); cardsToAdd.forEach(({ cardId, quantity }) => { const currentQty = newMap.get(cardId) || 0; newMap.set(cardId, currentQty + quantity); }); return newMap; }); setSnackbar({ message: `Successfully added ${cardsToAdd.length} card(s) to collection!`, type: 'success' }); } catch (error) { console.error('Error adding cards to collection:', error); setSnackbar({ message: 'Failed to add cards to collection', type: 'error' }); } finally { setIsAddingAll(false); setTimeout(() => setSnackbar(null), 3000); } }; const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); if (!searchQuery.trim()) return; setIsSearching(true); try { const cards = await searchCards(searchQuery); setSearchResults(cards || []); } catch (error) { console.error('Failed to search cards:', error); setSearchResults([]); setSnackbar({ message: 'Failed to search cards', type: 'error' }); } finally { setIsSearching(false); } }; const addCardToDeck = (card: Card) => { setSelectedCards(prev => { const isBasicLand = card.name === 'Plains' || card.name === 'Island' || card.name === 'Swamp' || card.name === 'Mountain' || card.name === 'Forest'; const existing = prev.find(c => c.card.id === card.id); if (existing) { return prev.map(c => c.card.id === card.id ? { ...c, quantity: isBasicLand ? c.quantity + 1 : Math.min(c.quantity + 1, 4), } : c ); } return [...prev, { card, quantity: 1 }]; }); }; const removeCardFromDeck = (cardId: string) => setSelectedCards(prev => prev.filter(c => c.card.id !== cardId)); const updateCardQuantity = (cardId: string, quantity: number) => { setSelectedCards(prev => { return prev.map(c => { if (c.card.id === cardId) { return { ...c, quantity: quantity }; } return c; }); }); }; const saveDeck = async () => { if (!deckName.trim() || selectedCards.length === 0 || !user) return; setIsSaving(true); try { const deckId = currentDeckId || crypto.randomUUID(); const deckToSave: Deck = { id: deckId, name: deckName, format: deckFormat, cards: selectedCards, userId: user.id, createdAt: initialDeck?.createdAt || new Date(), updatedAt: new Date(), }; // Calculate validation for storage const validation = validateDeck(deckToSave); // Determine cover card (commander or first card) const commanderCard = deckFormat === 'commander' ? selectedCards.find(c => c.card.id === commander?.id) : null; const coverCard = commanderCard?.card || selectedCards[0]?.card; const coverCardId = coverCard?.id || null; // Calculate total card count const totalCardCount = selectedCards.reduce((acc, curr) => acc + curr.quantity, 0); const deckData = { id: deckToSave.id, name: deckToSave.name, format: deckToSave.format, user_id: deckToSave.userId, created_at: deckToSave.createdAt, updated_at: deckToSave.updatedAt, cover_card_id: coverCardId, validation_errors: validation.errors, is_valid: validation.isValid, card_count: totalCardCount, }; // Save or update the deck const { error: deckError } = await supabase .from('decks') .upsert([deckData]) .select(); if (deckError) throw deckError; // Update current deck ID if this was a new deck if (!currentDeckId) { setCurrentDeckId(deckId); } // Delete existing cards if updating if (currentDeckId) { await supabase.from('deck_cards').delete().eq('deck_id', currentDeckId); } // Save the deck cards const deckCards = selectedCards.map(card => ({ deck_id: deckToSave.id, card_id: card.card.id, quantity: card.quantity, is_commander: card.card.id === commander?.id, })); const { error: cardsError } = await supabase .from('deck_cards') .insert(deckCards); if (cardsError) throw cardsError; setSnackbar({ message: 'Deck saved successfully!', type: 'success' }); if (onSave) onSave(); } catch (error) { console.error('Error saving deck:', error); setSnackbar({ message: 'Failed to save deck.', type: 'error' }); } finally { setIsSaving(false); setTimeout(() => setSnackbar(null), 3000); // Clear snackbar after 3 seconds } }; const currentDeck: Deck = { id: initialDeck?.id || '', name: deckName, format: deckFormat, cards: selectedCards, userId: user?.id || '', createdAt: initialDeck?.createdAt || new Date(), updatedAt: new Date(), }; const validation = validateDeck(currentDeck); // Commander color identity validation const commanderColors = deckFormat === 'commander' ? getCommanderColors(commander) : []; const invalidCards = deckFormat === 'commander' && commander ? selectedCards.filter(({ card }) => !isCardValidForCommander(card, commanderColors)) : []; const deckSize = selectedCards.reduce((acc, curr) => acc + curr.quantity, 0); const { landCount: suggestedLandCountValue, landDistribution: suggestedLands, } = suggestLandCountAndDistribution(selectedCards, deckFormat, commanderColors); const totalPrice = selectedCards.reduce((acc, { card, quantity }) => { const isBasicLand = card.name === 'Plains' || card.name === 'Island' || card.name === 'Swamp' || card.name === 'Mountain' || card.name === 'Forest'; const price = isBasicLand ? 0 : card.prices?.usd ? parseFloat(card.prices.usd) : 0; return acc + price * quantity; }, 0); const addSuggestedLandsToDeck = async () => { const basicLandCards = { W: { name: 'Plains', set: 'unh' }, U: { name: 'Island', set: 'unh' }, B: { name: 'Swamp', set: 'unh' }, R: { name: 'Mountain', set: 'unh' }, G: { name: 'Forest', set: 'unh' }, }; for (const color in suggestedLands) { const landCount = suggestedLands[color]; if (landCount > 0) { const landName = basicLandCards[color]?.name; const landSet = basicLandCards[color]?.set; if (landName && landSet) { try { const cards = await searchCards(`${landName} set:${landSet}`); if (cards && cards.length > 0) { const landCard = cards[0]; // Take the first matching card for (let i = 0; i < landCount; i++) { addCardToDeck(landCard); } } } catch (error) { console.error(`Failed to add ${landName}:`, error); } } } } }; const handleFileUpload = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0]; if (!file) return; setIsImporting(true); try { const reader = new FileReader(); reader.onload = async e => { const text = e.target?.result as string; const lines = text.split('\n'); const cardsToAdd: { card: Card; quantity: number }[] = []; for (const line of lines) { const parts = line.trim().split(' '); const quantity = parseInt(parts[0]); const cardName = parts.slice(1).join(' '); if (isNaN(quantity) || quantity <= 0 || !cardName) continue; try { const searchResults = await searchCards(cardName); if (searchResults && searchResults.length > 0) { const card = searchResults[0]; cardsToAdd.push({ card, quantity }); } else { console.warn(`Card not found: ${cardName}`); setSnackbar({ message: `Card not found: ${cardName}`, type: 'error' }); } } catch (error) { console.error(`Failed to search card ${cardName}:`, error); setSnackbar({ message: `Failed to import card: ${cardName}`, type: 'error' }); } } setSelectedCards(prev => { const updatedCards = [...prev]; for (const { card, quantity } of cardsToAdd) { const existingCardIndex = updatedCards.findIndex( c => c.card.id === card.id ); if (existingCardIndex !== -1) { updatedCards[existingCardIndex].quantity = Math.min( updatedCards[existingCardIndex].quantity + quantity, 4 ); } else { updatedCards.push({ card, quantity }); } } return updatedCards; }); }; reader.readAsText(file); } finally { setIsImporting(false); } }; return (
{/* Card Search Section */}
{/* Mobile-First Search Bar */}
setSearchQuery(e.target.value)} className="w-full pl-10 pr-24 py-3 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 text-white" placeholder="Rechercher une carte..." /> {searchQuery && ( )} {/* Vertical Card List for Mobile */}
{isSearching ? (
) : searchResults.length === 0 && searchQuery ? (

No cards found

Try a different search term

) : ( searchResults.map(card => { const currentFaceIndex = getCurrentFaceIndex(card.id); const isMultiFaced = isDoubleFaced(card); const inCollection = userCollection.get(card.id) || 0; const isAddingThisCard = addingCardId === card.id; const cardInDeck = selectedCards.find(c => c.card.id === card.id); const quantityInDeck = cardInDeck?.quantity || 0; const displayName = isMultiFaced && card.card_faces ? card.card_faces[currentFaceIndex]?.name || card.name : card.name; const isValidForCommander = deckFormat !== 'commander' || !commander || isCardValidForCommander(card, commanderColors); return (
setHoveredCard(card)} onMouseLeave={() => setHoveredCard(null)} onClick={() => setSelectedCard(card)} > {/* Card Thumbnail */}
e.stopPropagation()}> {getCardImageUri(card, currentFaceIndex) ? ( {displayName} ) : (
)} {isMultiFaced && ( )}
{/* Card Info */}

{displayName}

{card.mana_cost && ( )} {card.prices?.usd && (
${card.prices.usd}
)}
{inCollection > 0 && (
x{inCollection} in collection
)} {!isValidForCommander && (
Not in commander colors
)}
{/* Add/Quantity Controls */} {quantityInDeck > 0 ? (
e.stopPropagation()}> {quantityInDeck}
) : ( )} {/* Add to Collection Button (hidden on mobile by default) */}
); }) )}
{/* Deck Builder Section */}
setDeckName(e.target.value)} className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white" placeholder="Deck Name" /> {deckFormat === 'commander' && (
{commander && commanderColors.length > 0 && (
Commander Colors:
{commanderColors.map(color => ( ))}
)}
)}
{isImporting && (
)}
{!validation.isValid && (
    {validation.errors.map((error, index) => (
  • {error}
  • ))}
)} {/* Commander Color Identity Warning */} {deckFormat === 'commander' && commander && invalidCards.length > 0 && (

Commander Color Identity Warning

The following cards don't match your commander's color identity:

    {invalidCards.map(({ card }) => (
  • {card.name}
  • ))}
)}

Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})

{selectedCards.map(({ card, quantity }) => { const isValidForCommander = deckFormat !== 'commander' || !commander || isCardValidForCommander(card, commanderColors); return (
setHoveredCard(card)} onMouseLeave={() => setHoveredCard(null)} onClick={() => setSelectedCard(card)} > {card.name}

{card.name}

{card.prices?.usd && (
${card.prices.usd}
)} {!isValidForCommander && (
Not in commander colors
)}
e.stopPropagation()}> updateCardQuantity(card.id, parseInt(e.target.value)) } min="1" className="w-14 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center text-sm" />
); })}
{deckSize > 0 && suggestedLandCountValue > 0 && (
Suggested Lands {suggestedLandCountValue} total
{Object.entries(suggestedLands).map(([landType, count]) => count > 0 ? (
{count}
) : null )}
)}
{/* Fixed Footer with Price and Actions - Mobile First */}
{/* Total Price */}
Total Price ${totalPrice.toFixed(2)}
{/* Action Buttons */}
{!isLoadingCollection && getMissingCards().length > 0 && ( )}
{/* Hover Card Preview - only show if no card is selected */} {hoveredCard && !selectedCard && (() => { const currentFaceIndex = getCurrentFaceIndex(hoveredCard.id); const isMultiFaced = isDoubleFaced(hoveredCard); const currentFace = isMultiFaced && hoveredCard.card_faces ? hoveredCard.card_faces[currentFaceIndex] : null; const displayName = currentFace?.name || hoveredCard.name; const displayTypeLine = currentFace?.type_line || hoveredCard.type_line; const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text; return (
{displayName} {isMultiFaced && (
Face {currentFaceIndex + 1}/{hoveredCard.card_faces!.length}
)}

{displayName}

{displayTypeLine}

{displayOracleText && (

{displayOracleText}

)} {hoveredCard.prices?.usd && (
${hoveredCard.prices.usd}
)}
); })()} {/* Card Detail Panel - slides in from right */} {selectedCard && (() => { const currentFaceIndex = getCurrentFaceIndex(selectedCard.id); const isMultiFaced = isDoubleFaced(selectedCard); const currentFace = isMultiFaced && selectedCard.card_faces ? selectedCard.card_faces[currentFaceIndex] : null; const displayName = currentFace?.name || selectedCard.name; const displayTypeLine = currentFace?.type_line || selectedCard.type_line; const displayOracleText = currentFace?.oracle_text || selectedCard.oracle_text; return ( <> {/* Backdrop */}
setSelectedCard(null)} /> {/* Sliding Panel */}
{/* Close button */}
{/* Card Image */}
{displayName} {isMultiFaced && ( <>
Face {currentFaceIndex + 1}/{selectedCard.card_faces!.length}
)}
{/* Card Info */}

{displayName}

{displayTypeLine}

{displayOracleText && (

{displayOracleText}

)} {selectedCard.prices?.usd && (
${selectedCard.prices.usd} each
)} {/* Collection Status */} {userCollection.has(selectedCard.id) && (
x{userCollection.get(selectedCard.id)} in your collection
)} {/* Deck Quantity Management */}

Quantity in Deck

{selectedCards.find(c => c.card.id === selectedCard.id)?.quantity || 0}
copies
); })()} {snackbar && (
{snackbar.type === 'success' ? ( ) : ( )} {snackbar.message}
)}
); }