diff --git a/src/components/scanner/BulkOperations.tsx b/src/components/scanner/BulkOperations.tsx new file mode 100644 index 0000000..0161184 --- /dev/null +++ b/src/components/scanner/BulkOperations.tsx @@ -0,0 +1,266 @@ +import React, { useState } from 'react'; + +interface Collection { + id: string; + name: string; +} + +interface Deck { + id: string; + name: string; +} + +interface BulkOperationsProps { + selectedCount: number; + onAddToCollection: (collectionId: string, newCollectionName?: string) => void; + onAddToDeck: (deckId: string, newDeckName?: string) => void; + onAddTags: (tags: string[]) => void; + onRemoveSelected: () => void; + collections: Collection[]; + decks: Deck[]; + isProcessing: boolean; +} + +const BulkOperations: React.FC = ({ + selectedCount, + onAddToCollection, + onAddToDeck, + onAddTags, + onRemoveSelected, + collections, + decks, + isProcessing +}) => { + const [showTagInput, setShowTagInput] = useState(false); + const [tagInput, setTagInput] = useState(''); + const [showNewCollection, setShowNewCollection] = useState(false); + const [newCollectionName, setNewCollectionName] = useState(''); + const [showNewDeck, setShowNewDeck] = useState(false); + const [newDeckName, setNewDeckName] = useState(''); + + if (selectedCount === 0) return null; + + const handleAddTags = () => { + if (tagInput.trim()) { + const tags = tagInput.split(',').map(tag => tag.trim()).filter(Boolean); + onAddTags(tags); + setTagInput(''); + setShowTagInput(false); + } + }; + + const handleCreateCollection = () => { + if (newCollectionName.trim()) { + onAddToCollection('', newCollectionName.trim()); + setNewCollectionName(''); + setShowNewCollection(false); + } + }; + + const handleCreateDeck = () => { + if (newDeckName.trim()) { + onAddToDeck('', newDeckName.trim()); + setNewDeckName(''); + setShowNewDeck(false); + } + }; + + return ( +
+
+

+ Bulk Operations ({selectedCount} selected) +

+ + +
+ +
+ {/* Add to Collection */} +
+

Add to Collection

+ + {collections.length > 0 && ( + + )} + + {showNewCollection ? ( +
+ setNewCollectionName(e.target.value)} + placeholder="Collection name..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isProcessing} + /> + + +
+ ) : ( + + )} +
+ + {/* Add to Deck */} +
+

Add to Deck

+ + {decks.length > 0 && ( + + )} + + {showNewDeck ? ( +
+ setNewDeckName(e.target.value)} + placeholder="Deck name..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isProcessing} + /> + + +
+ ) : ( + + )} +
+ + {/* Add Tags */} +
+

Add Tags

+ + {showTagInput ? ( +
+ setTagInput(e.target.value)} + placeholder="Enter tags (comma-separated)..." + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isProcessing} + onKeyPress={(e) => e.key === 'Enter' && handleAddTags()} + /> +
+ + +
+
+ ) : ( + + )} + + {/* Common Tag Suggestions */} +
+ {['Foil', 'Mint', 'Near Mint', 'Played', 'Favorite'].map(tag => ( + + ))} +
+
+
+ + {/* Processing Indicator */} + {isProcessing && ( +
+
+ Processing... +
+ )} +
+ ); +}; + +export default BulkOperations; \ No newline at end of file diff --git a/src/components/scanner/CardQueue.tsx b/src/components/scanner/CardQueue.tsx new file mode 100644 index 0000000..fed0568 --- /dev/null +++ b/src/components/scanner/CardQueue.tsx @@ -0,0 +1,207 @@ +import React, { useState, useCallback } from 'react'; + +interface ScannedCard { + id: string; + cardData: { + cardName?: string; + setName?: string; + rarity?: string; + game?: string; + confidence?: number; + }; + imageDataUrl: string; + timestamp: number; + queuePosition: number; +} + +interface CardQueueProps { + cards: ScannedCard[]; + onRemoveCard: (cardId: string) => void; + onClearQueue: () => void; + onCardSelect: (cardId: string, selected: boolean) => void; + selectedCards: Set; + onViewCard: (card: ScannedCard) => void; +} + +const CardQueue: React.FC = ({ + cards, + onRemoveCard, + onClearQueue, + onCardSelect, + selectedCards, + onViewCard +}) => { + const [selectAll, setSelectAll] = useState(false); + + const handleSelectAll = useCallback(() => { + const newSelectAll = !selectAll; + setSelectAll(newSelectAll); + + cards.forEach(card => { + onCardSelect(card.id, newSelectAll); + }); + }, [selectAll, cards, onCardSelect]); + + const getConfidenceColor = (confidence?: number) => { + if (!confidence) return 'bg-gray-400'; + if (confidence >= 0.9) return 'bg-green-500'; + if (confidence >= 0.7) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + const getConfidenceText = (confidence?: number) => { + if (!confidence) return 'Unknown'; + return `${Math.round(confidence * 100)}%`; + }; + + if (cards.length === 0) { + return ( +
+
πŸ“·
+

Queue is Empty

+

Scanned cards will appear here

+
+ ); + } + + return ( +
+ {/* Queue Header */} +
+
+

+ Scanned Cards ({cards.length}/100) +

+ + {/* Select All Checkbox */} + +
+ + {/* Bulk Actions */} +
+ {selectedCards.size > 0 && ( + + {selectedCards.size} selected + + )} + + +
+
+ + {/* Card Grid */} +
+ {cards.map((card) => ( +
+ {/* Selection Checkbox */} +
+ onCardSelect(card.id, e.target.checked)} + className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 bg-white shadow-sm" + /> +
+ + {/* Remove Button */} + + + {/* Card Image */} +
onViewCard(card)} + > + {card.cardData.cardName +
+ + {/* Card Info */} +
+
+
+

+ {card.cardData.cardName || 'Unknown Card'} +

+ {card.cardData.setName && ( +

+ {card.cardData.setName} +

+ )} +
+ + {/* Confidence Badge */} +
+ {getConfidenceText(card.cardData.confidence)} +
+
+ + {/* Additional Info */} +
+ {card.cardData.game || 'Unknown Game'} + {card.cardData.rarity && ( + {card.cardData.rarity} + )} +
+ + {/* Queue Position */} +
+ #{card.queuePosition} +
+
+
+ ))} +
+ + {/* Queue Stats */} +
+
+ Total: {cards.length} cards + Selected: {selectedCards.size} +
+ +
+ +
+ High confidence +
+ +
+ Medium +
+ +
+ Low +
+
+
+
+ ); +}; + +export default CardQueue; \ No newline at end of file diff --git a/src/components/scanner/ScannerWizard.tsx b/src/components/scanner/ScannerWizard.tsx new file mode 100644 index 0000000..920359b --- /dev/null +++ b/src/components/scanner/ScannerWizard.tsx @@ -0,0 +1,431 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ScanModeSelector from './ScanModeSelector'; +import AutoScanningCamera from './AutoScanningCamera'; +import CardQueue from './CardQueue'; +import BulkOperations from './BulkOperations'; +import AutoTagger from '../../services/autoTagger'; + +type ScanMode = 'add-to-database' | 'search-collections' | 'price-check' | 'deck-building'; +type WizardStep = 'mode-selection' | 'scanning' | 'review' | 'processing' | 'complete'; + +interface ScannedCard { + id: string; + cardData: { + cardName?: string; + setName?: string; + rarity?: string; + game?: string; + confidence?: number; + cardType?: string; + manaCost?: string; + power?: string; + toughness?: string; + element?: string; + }; + imageDataUrl: string; + timestamp: number; + queuePosition: number; + autoTags?: string[]; +} + +interface Collection { + id: string; + name: string; +} + +interface Deck { + id: string; + name: string; +} + +const ScannerWizard: React.FC = () => { + const navigate = useNavigate(); + + // Wizard state + const [currentStep, setCurrentStep] = useState('mode-selection'); + const [scanMode, setScanMode] = useState(null); + const [scannedCards, setScannedCards] = useState([]); + const [selectedCards, setSelectedCards] = useState>(new Set()); + const [isProcessing, setIsProcessing] = useState(false); + + // Mock data - replace with actual API calls + const [collections] = useState([ + { id: '1', name: 'My Collection' }, + { id: '2', name: 'Trade Binder' }, + { id: '3', name: 'Deck Ideas' } + ]); + + const [decks] = useState([ + { id: '1', name: 'Standard Deck' }, + { id: '2', name: 'Commander' }, + { id: '3', name: 'Draft Picks' } + ]); + + // Local image cache for session + const [imageCache] = useState>(new Map()); + + // Memory cleanup + useEffect(() => { + return () => { + // Cleanup image URLs when component unmounts + imageCache.forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + imageCache.clear(); + }; + }, [imageCache]); + + // Handle mode selection + const handleModeSelect = useCallback((mode: ScanMode) => { + setScanMode(mode); + setCurrentStep('scanning'); + }, []); + + // Handle card scanned + const handleCardScanned = useCallback((card: ScannedCard) => { + // Generate auto-tags + const autoTagResult = AutoTagger.generateTags(card.cardData); + + const enhancedCard: ScannedCard = { + ...card, + autoTags: autoTagResult.tags + }; + + // Cache the image + imageCache.set(card.id, card.imageDataUrl); + + // Add to queue + setScannedCards(prev => [...prev, enhancedCard]); + + // Memory management - remove oldest if over limit + setScannedCards(prev => { + if (prev.length > 100) { + const removed = prev.slice(0, prev.length - 100); + // Cleanup removed images + removed.forEach(removedCard => { + const cachedUrl = imageCache.get(removedCard.id); + if (cachedUrl && cachedUrl.startsWith('blob:')) { + URL.revokeObjectURL(cachedUrl); + } + imageCache.delete(removedCard.id); + }); + return prev.slice(prev.length - 100); + } + return prev; + }); + }, [imageCache]); + + // Handle card removal + const handleRemoveCard = useCallback((cardId: string) => { + setScannedCards(prev => prev.filter(card => card.id !== cardId)); + setSelectedCards(prev => { + const newSet = new Set(prev); + newSet.delete(cardId); + return newSet; + }); + + // Cleanup cached image + const cachedUrl = imageCache.get(cardId); + if (cachedUrl && cachedUrl.startsWith('blob:')) { + URL.revokeObjectURL(cachedUrl); + } + imageCache.delete(cardId); + }, [imageCache]); + + // Handle queue clear + const handleClearQueue = useCallback(() => { + // Cleanup all cached images + scannedCards.forEach(card => { + const cachedUrl = imageCache.get(card.id); + if (cachedUrl && cachedUrl.startsWith('blob:')) { + URL.revokeObjectURL(cachedUrl); + } + }); + imageCache.clear(); + + setScannedCards([]); + setSelectedCards(new Set()); + }, [scannedCards, imageCache]); + + // Handle card selection + const handleCardSelect = useCallback((cardId: string, selected: boolean) => { + setSelectedCards(prev => { + const newSet = new Set(prev); + if (selected) { + newSet.add(cardId); + } else { + newSet.delete(cardId); + } + return newSet; + }); + }, []); + + // Handle card view (full screen) + const handleViewCard = useCallback((card: ScannedCard) => { + // TODO: Implement full-screen card viewer modal + console.log('Viewing card:', card); + }, []); + + // Handle bulk operations + const handleAddToCollection = useCallback(async (collectionId: string, newCollectionName?: string) => { + if (selectedCards.size === 0) return; + + setIsProcessing(true); + try { + // TODO: Implement actual API calls + console.log('Adding to collection:', { collectionId, newCollectionName, cards: Array.from(selectedCards) }); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Success feedback + alert(`Added ${selectedCards.size} cards to ${newCollectionName || 'collection'}`); + + } catch (error) { + console.error('Failed to add to collection:', error); + alert('Failed to add cards to collection'); + } finally { + setIsProcessing(false); + } + }, [selectedCards]); + + const handleAddToDeck = useCallback(async (deckId: string, newDeckName?: string) => { + if (selectedCards.size === 0) return; + + setIsProcessing(true); + try { + // TODO: Implement actual API calls + console.log('Adding to deck:', { deckId, newDeckName, cards: Array.from(selectedCards) }); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Success feedback + alert(`Added ${selectedCards.size} cards to ${newDeckName || 'deck'}`); + + } catch (error) { + console.error('Failed to add to deck:', error); + alert('Failed to add cards to deck'); + } finally { + setIsProcessing(false); + } + }, [selectedCards]); + + const handleAddTags = useCallback(async (tags: string[]) => { + if (selectedCards.size === 0) return; + + setIsProcessing(true); + try { + // TODO: Implement actual API calls + console.log('Adding tags:', { tags, cards: Array.from(selectedCards) }); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Success feedback + alert(`Added tags "${tags.join(', ')}" to ${selectedCards.size} cards`); + + } catch (error) { + console.error('Failed to add tags:', error); + alert('Failed to add tags'); + } finally { + setIsProcessing(false); + } + }, [selectedCards]); + + const handleRemoveSelected = useCallback(() => { + selectedCards.forEach(cardId => handleRemoveCard(cardId)); + }, [selectedCards, handleRemoveCard]); + + // Handle finish workflow + const handleFinish = useCallback(async () => { + if (scannedCards.length === 0) { + navigate('/scanner'); + return; + } + + setCurrentStep('processing'); + setIsProcessing(true); + + try { + // Process all cards based on scan mode + switch (scanMode) { + case 'add-to-database': + // TODO: Add all cards to database + console.log('Adding all cards to database:', scannedCards); + break; + case 'search-collections': + // TODO: Search for cards in collections + console.log('Searching collections for:', scannedCards); + break; + case 'price-check': + // TODO: Get pricing for all cards + console.log('Getting prices for:', scannedCards); + break; + case 'deck-building': + // TODO: Add to deck with legality checks + console.log('Adding to deck:', scannedCards); + break; + } + + // Simulate processing + await new Promise(resolve => setTimeout(resolve, 2000)); + + setCurrentStep('complete'); + + // Auto-redirect after success + setTimeout(() => { + navigate('/scanner'); + }, 3000); + + } catch (error) { + console.error('Failed to process cards:', error); + alert('Failed to process cards'); + setCurrentStep('review'); + } finally { + setIsProcessing(false); + } + }, [scannedCards, scanMode, navigate]); + + // Navigation handlers + const handleBackToScanning = useCallback(() => { + setCurrentStep('scanning'); + }, []); + + const handleProceedToReview = useCallback(() => { + setCurrentStep('review'); + }, []); + + const handleError = useCallback((error: string) => { + console.error('Scanner error:', error); + alert(error); + }, []); + + // Render current step + const renderStep = () => { + switch (currentStep) { + case 'mode-selection': + return ; + + case 'scanning': + return ( +
+
+

+ {scanMode === 'add-to-database' && 'πŸ“š Adding Cards to Database'} + {scanMode === 'search-collections' && 'πŸ” Searching Collections'} + {scanMode === 'price-check' && 'πŸ’° Price Checking Cards'} + {scanMode === 'deck-building' && '🎯 Building Deck'} +

+ +
+ {scannedCards.length > 0 && ( + + )} + +
+
+ + +
+ ); + + case 'review': + return ( +
+
+

Review Scanned Cards

+ +
+ + +
+
+ + + + +
+ ); + + case 'processing': + return ( +
+
+

Processing Cards...

+

Adding {scannedCards.length} cards to your collection

+
+ ); + + case 'complete': + return ( +
+
βœ…
+

Success!

+

+ Successfully processed {scannedCards.length} cards +

+

Redirecting to scanner...

+
+ ); + + default: + return null; + } + }; + + return ( +
+
+ {renderStep()} +
+
+ ); +}; + +export default ScannerWizard; \ No newline at end of file diff --git a/src/pages/Scanner.tsx b/src/pages/Scanner.tsx index 7e3feb4..89230a5 100644 --- a/src/pages/Scanner.tsx +++ b/src/pages/Scanner.tsx @@ -1,621 +1,8 @@ -import React, { useState, useEffect } from 'react'; -import CameraScanner from '../components/CameraScanner'; -import GlowingCard from '../components/GlowingCard'; -import CardImageDisplay from '../components/CardImageDisplay'; -import { cardMatcher } from '../services/cardMatcher'; -import { useAuth } from '../contexts/AuthContext'; - -interface ScannedCard { - id: string; - originalName: string; - ocrText: string; - ocrConfidence: number; - matches: any[]; - selectedMatch?: any; - timestamp: number; -} - -interface Collection { - id: string; - name: string; - description?: string; - totalCards: number; - totalValue: number; -} +import React from 'react'; +import ScannerWizard from '../components/scanner/ScannerWizard'; const Scanner: React.FC = () => { - const { user } = useAuth(); - const [isDragging, setIsDragging] = useState(false); - const [scannedCards, setScannedCards] = useState([]); - const [isProcessingMatch, setIsProcessingMatch] = useState(false); - const [error, setError] = useState(null); - const [collections, setCollections] = useState([]); - const [selectedCollectionId, setSelectedCollectionId] = useState(null); - const [isLoadingCollections, setIsLoadingCollections] = useState(false); - const [isAddingToCollection, setIsAddingToCollection] = useState(false); - - // Load user's collections on component mount - useEffect(() => { - if (user) { - loadCollections(); - } - }, [user]); - - const loadCollections = async () => { - setIsLoadingCollections(true); - try { - const token = localStorage.getItem('token'); - const response = await fetch('/api/collections', { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - - if (response.ok) { - const data = await response.json(); - setCollections(data.collections || []); - - // Auto-select first collection if available - if (data.collections && data.collections.length > 0) { - setSelectedCollectionId(data.collections[0].id); - } - } else { - console.error('Failed to load collections'); - } - } catch (error) { - console.error('Error loading collections:', error); - } finally { - setIsLoadingCollections(false); - } - }; - - const createNewCollection = async (name: string, description?: string) => { - try { - const token = localStorage.getItem('token'); - const response = await fetch('/api/collections', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: name.trim(), - description: description?.trim() || null, - isPublic: false - }) - }); - - if (response.ok) { - const data = await response.json(); - const newCollection = data.collection; - setCollections(prev => [newCollection, ...prev]); - setSelectedCollectionId(newCollection.id); - return newCollection; - } else { - throw new Error('Failed to create collection'); - } - } catch (error) { - console.error('Error creating collection:', error); - throw error; - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }; - - // Handle card scanned from camera - const handleCardScanned = async (ocrData: any) => { - setIsProcessingMatch(true); - setError(null); - - try { - // Use card matcher to find potential matches - const matches = await cardMatcher.matchCard({ - name: ocrData.name, - set: ocrData.set, - ocrText: ocrData.ocrText, - confidence: ocrData.confidence - }); - - // Create scanned card entry - const scannedCard: ScannedCard = { - id: Date.now().toString(), - originalName: ocrData.name, - ocrText: ocrData.ocrText, - ocrConfidence: ocrData.confidence, - matches: matches, - selectedMatch: matches.length > 0 ? matches[0].card : undefined, - timestamp: Date.now() - }; - - setScannedCards(prev => [scannedCard, ...prev]); - console.log('Card scan processed:', scannedCard); - - } catch (err) { - console.error('Card matching error:', err); - setError('Failed to match card against database.'); - } finally { - setIsProcessingMatch(false); - } - }; - - // Handle scanner errors - const handleScannerError = (errorMessage: string) => { - setError(errorMessage); - }; - - // Select a different match for a scanned card - const selectMatch = (scannedCardId: string, match: any) => { - setScannedCards(prev => prev.map(card => - card.id === scannedCardId - ? { ...card, selectedMatch: match.card } - : card - )); - }; - - // Remove a scanned card - const removeScannedCard = (scannedCardId: string) => { - setScannedCards(prev => prev.filter(card => card.id !== scannedCardId)); - }; - - // Add selected card to collection - const addToCollection = async (scannedCardId: string) => { - const scannedCard = scannedCards.find(c => c.id === scannedCardId); - if (!scannedCard || !scannedCard.selectedMatch) return; - - if (!selectedCollectionId) { - setError('Please select a collection first.'); - return; - } - - setIsAddingToCollection(true); - try { - const token = localStorage.getItem('token'); - - // First, ensure the card exists in our database - let cardId = scannedCard.selectedMatch.id; - - if (!cardId) { - // Need to find or create the card - const findOrCreateResponse = await fetch('/api/cards/find-or-create', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: scannedCard.selectedMatch.name, - game: scannedCard.selectedMatch.game || 'MTG', - setName: scannedCard.selectedMatch.set_name, - setCode: scannedCard.selectedMatch.set_code, - rarity: scannedCard.selectedMatch.rarity, - cardType: scannedCard.selectedMatch.card_type, - manaCost: scannedCard.selectedMatch.mana_cost, - ocrConfidence: scannedCard.ocrConfidence, - ocrRawText: scannedCard.ocrText - }) - }); - - if (findOrCreateResponse.ok) { - const cardData = await findOrCreateResponse.json(); - cardId = cardData.card.id; - } else { - throw new Error('Failed to find or create card in database'); - } - } - - // Now add the card to the collection - const addResponse = await fetch('/api/collections/add-card', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - collectionId: selectedCollectionId, - cardId: cardId, - quantity: 1, - condition: 'near-mint', - notes: `Added via OCR scan (${Math.round(scannedCard.ocrConfidence)}% confidence)` - }) - }); - - if (addResponse.ok) { - const result = await addResponse.json(); - - // Update collection stats - setCollections(prev => prev.map(collection => - collection.id === selectedCollectionId - ? { - ...collection, - totalCards: result.result.collectionStats.totalCards, - totalValue: result.result.collectionStats.totalValue - } - : collection - )); - - // Show success message and remove from scan results - setError(null); - removeScannedCard(scannedCardId); - - // You could show a success toast here instead - console.log(`βœ… ${result.message}:`, result.result.card.name); - } else { - const errorData = await addResponse.json(); - throw new Error(errorData.error || 'Failed to add card to collection'); - } - } catch (err: any) { - console.error('Failed to add to collection:', err); - setError(`Failed to add card to collection: ${err.message}`); - } finally { - setIsAddingToCollection(false); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - // TODO: Handle file upload and OCR processing - }; - - return ( -
-
-
-

Card Scanner

-
-

- Use AI-powered OCR to quickly scan and identify your trading cards -

-
- - - - {/* Error Display */} - {error && ( -
-
- ❌ -
-

Error

-

{error}

-
- -
-
- )} - - {/* Collection Selection */} - {user && ( -
-

πŸ“š Collection Selection

- - {isLoadingCollections ? ( -
-
- Loading collections... -
- ) : collections.length > 0 ? ( -
-
- - -
- - {selectedCollectionId && ( -
- βœ… Cards will be added to: {collections.find(c => c.id === selectedCollectionId)?.name} -
- )} -
- ) : ( -
-
- You don't have any collections yet. Create one to start adding scanned cards! -
- -
- )} -
- )} - - {/* Camera Scanner */} -
-

πŸ“Έ Live Camera Scanner

- - - {isProcessingMatch && ( -
-
-
-
-
Matching card against database...
-
This may take a moment
-
-
-
- )} -
- - {/* Upload Area */} -
- πŸ“Έ -

- Upload Card Images -

-

- Drag and drop your card images here, or click to select files -

-
- - -
-

- Supported formats: JPG, PNG, HEIC β€’ Max size: 10MB per image -

-
- - {/* Supported Games */} -
-

- Supported Trading Card Games -

-
-
- βœ… -
-

Magic: The Gathering

-

Full OCR support

-
-
-
- βœ… -
-

PokΓ©mon

-

Full OCR support

-
-
-
- πŸ†• -
-

Disney Lorcana

-

Ready for scanning

-
-
-
-
- - {/* Scan Results */} - {scannedCards.length > 0 && ( -
-

- 🎯 Scan Results ({scannedCards.length}) -

-
- {scannedCards.map((scannedCard) => ( -
- {/* Scan Info Header */} -
-
-

- OCR Result: "{scannedCard.originalName}" -

-
- Confidence: {Math.round(scannedCard.ocrConfidence)}% - β€’ - {new Date(scannedCard.timestamp).toLocaleTimeString()} -
-
- -
- - {/* Card Matches */} - {scannedCard.matches.length > 0 ? ( -
-
-
- Found {scannedCard.matches.length} potential match{scannedCard.matches.length !== 1 ? 'es' : ''}: -
- - {/* Selected Match Display */} - {scannedCard.selectedMatch && ( -
-
-
- - - -
-
-
-
-
- {scannedCard.selectedMatch.name} -
-

- {scannedCard.selectedMatch.set_name} β€’ {scannedCard.selectedMatch.rarity} -

-

- Match confidence: {Math.round((scannedCard.matches.find(m => m.card.id === scannedCard.selectedMatch.id)?.confidence || 0) * 100)}% -

-
- -
-
-
-
- )} - - {/* Alternative Matches */} - {scannedCard.matches.length > 1 && ( -
-

Other potential matches:

-
- {scannedCard.matches - .filter(match => match.card.id !== scannedCard.selectedMatch?.id) - .slice(0, 6) - .map((match, index) => ( -
selectMatch(scannedCard.id, match)} - > -
-
- -
-
-

- {match.card.name} -

-

- {match.card.set_name} -

-

- {Math.round(match.confidence * 100)}% match -

-
-
-
- ))} -
-
- )} -
-
- ) : ( -
-
- ⚠️ -
-

No matches found

-

- The card couldn't be matched against our database. Try scanning again with better lighting. -

-
-
- - {/* Show OCR text for debugging */} -
- - View raw OCR text - -
-                        {scannedCard.ocrText}
-                      
-
-
- )} -
- ))} -
-
- )} - - {/* Tips */} -
-

- πŸ’‘ Tips for Best Results -

-
    -
  • β€’ Ensure good lighting and minimal shadows
  • -
  • β€’ Keep cards flat and avoid glare
  • -
  • β€’ Capture the entire card including borders
  • -
  • β€’ Use high resolution images when possible
  • -
  • β€’ For foil cards, angle to reduce glare
  • -
-
- - -
- ); + return ; }; export default Scanner; \ No newline at end of file diff --git a/src/services/autoTagger.ts b/src/services/autoTagger.ts new file mode 100644 index 0000000..8ba49bc --- /dev/null +++ b/src/services/autoTagger.ts @@ -0,0 +1,305 @@ +interface CardData { + cardName?: string; + setName?: string; + rarity?: string; + game?: string; + cardType?: string; + manaCost?: string; + power?: string; + toughness?: string; + element?: string; + series?: string; +} + +interface AutoTagResult { + tags: string[]; + confidence: number; + reasoning: string; +} + +export class AutoTagger { + + /** + * Generate auto-tags based on card data + */ + static generateTags(cardData: CardData): AutoTagResult { + const tags: string[] = []; + let confidence = 0.8; + const reasons: string[] = []; + + // Game-specific tags + if (cardData.game) { + const gameTag = this.normalizeGame(cardData.game); + tags.push(gameTag); + reasons.push(`Game: ${gameTag}`); + } + + // Rarity-based tags + if (cardData.rarity) { + const rarityTag = this.normalizeRarity(cardData.rarity); + tags.push(rarityTag); + reasons.push(`Rarity: ${rarityTag}`); + + // Special rarity indicators + if (this.isHighValueRarity(cardData.rarity)) { + tags.push('High Value'); + reasons.push('High-value rarity detected'); + } + } + + // Set-based tags + if (cardData.setName) { + // Add set abbreviation if recognizable + const setTag = this.generateSetTag(cardData.setName); + if (setTag) { + tags.push(setTag); + reasons.push(`Set: ${setTag}`); + } + + // Detect special sets + const specialSetTags = this.detectSpecialSets(cardData.setName); + tags.push(...specialSetTags); + if (specialSetTags.length > 0) { + reasons.push(`Special sets: ${specialSetTags.join(', ')}`); + } + } + + // Card type tags (MTG specific) + if (cardData.cardType) { + const typeTag = this.normalizeCardType(cardData.cardType); + if (typeTag) { + tags.push(typeTag); + reasons.push(`Type: ${typeTag}`); + } + + // Detect creature specifics + if (cardData.power && cardData.toughness) { + tags.push('Creature'); + const powerLevel = this.categorizeCreaturePower(cardData.power, cardData.toughness); + if (powerLevel) { + tags.push(powerLevel); + reasons.push(`Power level: ${powerLevel}`); + } + } + } + + // Mana cost analysis (MTG) + if (cardData.manaCost) { + const manaTags = this.analyzeManaColor(cardData.manaCost); + tags.push(...manaTags); + if (manaTags.length > 0) { + reasons.push(`Mana colors: ${manaTags.join(', ')}`); + } + } + + // Element analysis (Pokemon) + if (cardData.element) { + const elementTag = this.normalizeElement(cardData.element); + if (elementTag) { + tags.push(elementTag); + reasons.push(`Element: ${elementTag}`); + } + } + + // Card name analysis + if (cardData.cardName) { + const nameTags = this.analyzeCardName(cardData.cardName); + tags.push(...nameTags); + if (nameTags.length > 0) { + reasons.push(`Name analysis: ${nameTags.join(', ')}`); + } + } + + // Remove duplicates and clean up + const uniqueTags = Array.from(new Set(tags)).filter(tag => tag.length > 0); + + return { + tags: uniqueTags, + confidence, + reasoning: reasons.join('; ') + }; + } + + private static normalizeGame(game: string): string { + const gameMap: Record = { + 'magic the gathering': 'MTG', + 'magic': 'MTG', + 'mtg': 'MTG', + 'pokemon': 'Pokemon', + 'pokΓ©mon': 'Pokemon', + 'lorcana': 'Lorcana', + 'disney lorcana': 'Lorcana' + }; + + return gameMap[game.toLowerCase()] || game; + } + + private static normalizeRarity(rarity: string): string { + const rarityMap: Record = { + 'c': 'Common', + 'common': 'Common', + 'u': 'Uncommon', + 'uncommon': 'Uncommon', + 'r': 'Rare', + 'rare': 'Rare', + 'm': 'Mythic', + 'mythic': 'Mythic', + 'mythic rare': 'Mythic', + 'sr': 'Super Rare', + 'super rare': 'Super Rare', + 'ultra rare': 'Ultra Rare', + 'secret rare': 'Secret Rare', + 'legendary': 'Legendary', + 'enchanted': 'Enchanted' + }; + + return rarityMap[rarity.toLowerCase()] || rarity; + } + + private static isHighValueRarity(rarity: string): boolean { + const highValueRarities = ['mythic', 'super rare', 'ultra rare', 'secret rare', 'legendary', 'enchanted']; + return highValueRarities.some(hvr => rarity.toLowerCase().includes(hvr)); + } + + private static generateSetTag(setName: string): string | null { + // Common set abbreviations + const setAbbreviations: Record = { + 'dominaria united': 'DMU', + 'brothers war': 'BRO', + 'phyrexia all will be one': 'ONE', + 'march of the machine': 'MOM', + 'battle for zendikar': 'BFZ', + 'sword shield': 'SWSH', + 'sun moon': 'SM', + 'the first chapter': 'TFC' + }; + + const lowerSet = setName.toLowerCase(); + return setAbbreviations[lowerSet] || null; + } + + private static detectSpecialSets(setName: string): string[] { + const tags: string[] = []; + const lowerSet = setName.toLowerCase(); + + if (lowerSet.includes('promo')) tags.push('Promo'); + if (lowerSet.includes('prerelease')) tags.push('Prerelease'); + if (lowerSet.includes('foil')) tags.push('Foil'); + if (lowerSet.includes('alternate art')) tags.push('Alt Art'); + if (lowerSet.includes('full art')) tags.push('Full Art'); + if (lowerSet.includes('borderless')) tags.push('Borderless'); + if (lowerSet.includes('showcase')) tags.push('Showcase'); + if (lowerSet.includes('collector')) tags.push('Collector'); + + return tags; + } + + private static normalizeCardType(cardType: string): string | null { + const typeMap: Record = { + 'creature': 'Creature', + 'instant': 'Instant', + 'sorcery': 'Sorcery', + 'artifact': 'Artifact', + 'enchantment': 'Enchantment', + 'planeswalker': 'Planeswalker', + 'land': 'Land', + 'basic land': 'Basic Land', + 'legendary': 'Legendary' + }; + + const lowerType = cardType.toLowerCase(); + for (const [key, value] of Object.entries(typeMap)) { + if (lowerType.includes(key)) { + return value; + } + } + + return null; + } + + private static categorizeCreaturePower(power: string, toughness: string): string | null { + const powerNum = parseInt(power); + const toughnessNum = parseInt(toughness); + + if (isNaN(powerNum) || isNaN(toughnessNum)) return null; + + const totalStats = powerNum + toughnessNum; + + if (totalStats >= 10) return 'High Power'; + if (totalStats >= 6) return 'Mid Power'; + if (totalStats >= 3) return 'Low Power'; + return 'Utility'; + } + + private static analyzeManaColor(manaCost: string): string[] { + const colors: string[] = []; + + if (manaCost.includes('W') || manaCost.includes('white')) colors.push('White'); + if (manaCost.includes('U') || manaCost.includes('blue')) colors.push('Blue'); + if (manaCost.includes('B') || manaCost.includes('black')) colors.push('Black'); + if (manaCost.includes('R') || manaCost.includes('red')) colors.push('Red'); + if (manaCost.includes('G') || manaCost.includes('green')) colors.push('Green'); + + // Multi-color detection + if (colors.length > 1) { + colors.push('Multicolor'); + } else if (colors.length === 0 && manaCost.match(/\d+/)) { + colors.push('Colorless'); + } + + return colors; + } + + private static normalizeElement(element: string): string | null { + const elementMap: Record = { + 'fire': 'Fire', + 'water': 'Water', + 'grass': 'Grass', + 'electric': 'Electric', + 'psychic': 'Psychic', + 'fighting': 'Fighting', + 'darkness': 'Dark', + 'metal': 'Steel', + 'fairy': 'Fairy', + 'dragon': 'Dragon', + 'colorless': 'Colorless' + }; + + return elementMap[element.toLowerCase()] || element; + } + + private static analyzeCardName(cardName: string): string[] { + const tags: string[] = []; + const lowerName = cardName.toLowerCase(); + + // Legendary indicators + if (lowerName.includes('legendary') || this.isLegendaryName(lowerName)) { + tags.push('Legendary'); + } + + // Foil indicators in name + if (lowerName.includes('foil') || lowerName.includes('holo')) { + tags.push('Foil'); + } + + // Special card indicators + if (lowerName.includes('ex') || lowerName.includes('gx') || lowerName.includes('vmax')) { + tags.push('Special'); + } + + return tags; + } + + private static isLegendaryName(name: string): boolean { + // Common legendary name patterns + const legendaryPatterns = [ + /\b(jace|chandra|garruk|liliana|ajani|elspeth|vraska|nissa|gideon|teferi)\b/, + /\b(pikachu|charizard|mewtwo|mew|rayquaza|arceus)\b/, + /\b(mickey|minnie|donald|goofy|elsa|anna|simba)\b/ + ]; + + return legendaryPatterns.some(pattern => pattern.test(name)); + } +} + +export default AutoTagger; \ No newline at end of file