386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|||
|
|
import CameraScanner from '../components/CameraScanner';
|
|||
|
|
import GlowingCard from '../components/GlowingCard';
|
|||
|
|
import CardImageDisplay from '../components/CardImageDisplay';
|
|||
|
|
import { cardMatcher } from '../services/cardMatcher';
|
|||
|
|
|
|||
|
|
interface ScannedCard {
|
|||
|
|
id: string;
|
|||
|
|
originalName: string;
|
|||
|
|
ocrText: string;
|
|||
|
|
ocrConfidence: number;
|
|||
|
|
matches: any[];
|
|||
|
|
selectedMatch?: any;
|
|||
|
|
timestamp: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Scanner: React.FC = () => {
|
|||
|
|
const [isDragging, setIsDragging] = useState(false);
|
|||
|
|
const [scannedCards, setScannedCards] = useState<ScannedCard[]>([]);
|
|||
|
|
const [isProcessingMatch, setIsProcessingMatch] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// TODO: Implement collection service
|
|||
|
|
console.log('Adding to collection:', scannedCard.selectedMatch);
|
|||
|
|
// For now, just remove from scan results
|
|||
|
|
removeScannedCard(scannedCardId);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Failed to add to collection:', err);
|
|||
|
|
setError('Failed to add card to collection.');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDrop = (e: React.DragEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
setIsDragging(false);
|
|||
|
|
// TODO: Handle file upload and OCR processing
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div className="mb-8">
|
|||
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Card Scanner</h1>
|
|||
|
|
<p className="text-gray-600">
|
|||
|
|
Use OCR technology to quickly scan and identify your trading cards
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Error Display */}
|
|||
|
|
{error && (
|
|||
|
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
|||
|
|
<div className="flex items-center">
|
|||
|
|
<span className="text-red-600 text-xl mr-3">❌</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium text-red-900">Error</p>
|
|||
|
|
<p className="text-red-700">{error}</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setError(null)}
|
|||
|
|
className="ml-auto text-red-600 hover:text-red-800"
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Camera Scanner */}
|
|||
|
|
<div className="mb-8 bg-white rounded-lg shadow border border-gray-200 p-6">
|
|||
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">📸 Live Camera Scanner</h2>
|
|||
|
|
<CameraScanner
|
|||
|
|
onCardScanned={handleCardScanned}
|
|||
|
|
onError={handleScannerError}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{isProcessingMatch && (
|
|||
|
|
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|||
|
|
<div>
|
|||
|
|
<div className="font-medium text-blue-900">Matching card against database...</div>
|
|||
|
|
<div className="text-sm text-blue-600">This may take a moment</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Upload Area */}
|
|||
|
|
<div
|
|||
|
|
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
|
|||
|
|
isDragging
|
|||
|
|
? 'border-primary-400 bg-primary-50'
|
|||
|
|
: 'border-gray-300 hover:border-gray-400'
|
|||
|
|
}`}
|
|||
|
|
onDragOver={handleDragOver}
|
|||
|
|
onDragLeave={handleDragLeave}
|
|||
|
|
onDrop={handleDrop}
|
|||
|
|
>
|
|||
|
|
<span className="text-6xl mb-4 block">📸</span>
|
|||
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|||
|
|
Upload Card Images
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-gray-600 mb-6">
|
|||
|
|
Drag and drop your card images here, or click to select files
|
|||
|
|
</p>
|
|||
|
|
<div className="flex justify-center gap-4">
|
|||
|
|
<button className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
|
|||
|
|
Choose Files
|
|||
|
|
</button>
|
|||
|
|
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">
|
|||
|
|
Use Camera
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-gray-500 mt-4">
|
|||
|
|
Supported formats: JPG, PNG, HEIC • Max size: 10MB per image
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Supported Games */}
|
|||
|
|
<div className="mt-8 bg-white rounded-lg shadow border border-gray-200 p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|||
|
|
Supported Trading Card Games
|
|||
|
|
</h3>
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|||
|
|
<div className="flex items-center p-3 bg-green-50 rounded-lg">
|
|||
|
|
<span className="text-green-600 text-xl mr-3">✅</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium text-gray-900">Magic: The Gathering</p>
|
|||
|
|
<p className="text-sm text-gray-600">Full OCR support</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center p-3 bg-green-50 rounded-lg">
|
|||
|
|
<span className="text-green-600 text-xl mr-3">✅</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium text-gray-900">Pokémon</p>
|
|||
|
|
<p className="text-sm text-gray-600">Full OCR support</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center p-3 bg-blue-50 rounded-lg">
|
|||
|
|
<span className="text-blue-600 text-xl mr-3">🆕</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium text-gray-900">Disney Lorcana</p>
|
|||
|
|
<p className="text-sm text-gray-600">Ready for scanning</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Scan Results */}
|
|||
|
|
{scannedCards.length > 0 && (
|
|||
|
|
<div className="mt-8">
|
|||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|||
|
|
<span>🎯</span> Scan Results ({scannedCards.length})
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{scannedCards.map((scannedCard) => (
|
|||
|
|
<div key={scannedCard.id} className="bg-white rounded-lg shadow border border-gray-200 p-6">
|
|||
|
|
{/* Scan Info Header */}
|
|||
|
|
<div className="flex items-center justify-between mb-4">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="font-semibold text-gray-900">
|
|||
|
|
OCR Result: "{scannedCard.originalName}"
|
|||
|
|
</h4>
|
|||
|
|
<div className="text-sm text-gray-600 flex items-center gap-4">
|
|||
|
|
<span>Confidence: {Math.round(scannedCard.ocrConfidence)}%</span>
|
|||
|
|
<span>•</span>
|
|||
|
|
<span>{new Date(scannedCard.timestamp).toLocaleTimeString()}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => removeScannedCard(scannedCard.id)}
|
|||
|
|
className="text-gray-400 hover:text-red-600 text-xl"
|
|||
|
|
title="Remove scan result"
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Card Matches */}
|
|||
|
|
{scannedCard.matches.length > 0 ? (
|
|||
|
|
<div>
|
|||
|
|
<div className="mb-4">
|
|||
|
|
<h5 className="font-medium text-gray-900 mb-2">
|
|||
|
|
Found {scannedCard.matches.length} potential match{scannedCard.matches.length !== 1 ? 'es' : ''}:
|
|||
|
|
</h5>
|
|||
|
|
|
|||
|
|
{/* Selected Match Display */}
|
|||
|
|
{scannedCard.selectedMatch && (
|
|||
|
|
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
|||
|
|
<div className="flex items-start gap-4">
|
|||
|
|
<div className="flex-shrink-0">
|
|||
|
|
<GlowingCard
|
|||
|
|
rarity={scannedCard.selectedMatch.rarity}
|
|||
|
|
className="w-24 h-32"
|
|||
|
|
>
|
|||
|
|
<CardImageDisplay
|
|||
|
|
card={scannedCard.selectedMatch}
|
|||
|
|
size="small"
|
|||
|
|
className="w-full h-full"
|
|||
|
|
/>
|
|||
|
|
</GlowingCard>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h6 className="font-semibold text-green-900">
|
|||
|
|
{scannedCard.selectedMatch.name}
|
|||
|
|
</h6>
|
|||
|
|
<p className="text-sm text-green-700">
|
|||
|
|
{scannedCard.selectedMatch.set_name} • {scannedCard.selectedMatch.rarity}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-green-600 mt-1">
|
|||
|
|
Match confidence: {Math.round((scannedCard.matches.find(m => m.card.id === scannedCard.selectedMatch.id)?.confidence || 0) * 100)}%
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => addToCollection(scannedCard.id)}
|
|||
|
|
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
|
|||
|
|
>
|
|||
|
|
<span>➕</span> Add to Collection
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Alternative Matches */}
|
|||
|
|
{scannedCard.matches.length > 1 && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm text-gray-600 mb-3">Other potential matches:</p>
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|||
|
|
{scannedCard.matches
|
|||
|
|
.filter(match => match.card.id !== scannedCard.selectedMatch?.id)
|
|||
|
|
.slice(0, 6)
|
|||
|
|
.map((match, index) => (
|
|||
|
|
<div
|
|||
|
|
key={index}
|
|||
|
|
className="p-3 border border-gray-200 rounded-lg hover:border-blue-300 cursor-pointer transition-colors"
|
|||
|
|
onClick={() => selectMatch(scannedCard.id, match)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div className="flex-shrink-0">
|
|||
|
|
<CardImageDisplay
|
|||
|
|
card={match.card}
|
|||
|
|
size="small"
|
|||
|
|
className="w-12 h-16 rounded border"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className="font-medium text-sm text-gray-900 truncate">
|
|||
|
|
{match.card.name}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-gray-600 truncate">
|
|||
|
|
{match.card.set_name}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-blue-600">
|
|||
|
|
{Math.round(match.confidence * 100)}% match
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<span className="text-yellow-600 text-xl">⚠️</span>
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium text-yellow-900">No matches found</p>
|
|||
|
|
<p className="text-sm text-yellow-700">
|
|||
|
|
The card couldn't be matched against our database. Try scanning again with better lighting.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Show OCR text for debugging */}
|
|||
|
|
<details className="mt-3">
|
|||
|
|
<summary className="cursor-pointer text-sm text-yellow-800 hover:text-yellow-900">
|
|||
|
|
View raw OCR text
|
|||
|
|
</summary>
|
|||
|
|
<pre className="mt-2 text-xs bg-yellow-100 p-2 rounded border overflow-x-auto">
|
|||
|
|
{scannedCard.ocrText}
|
|||
|
|
</pre>
|
|||
|
|
</details>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Tips */}
|
|||
|
|
<div className="mt-8 bg-blue-50 rounded-lg p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">
|
|||
|
|
💡 Tips for Best Results
|
|||
|
|
</h3>
|
|||
|
|
<ul className="space-y-2 text-blue-800">
|
|||
|
|
<li>• Ensure good lighting and minimal shadows</li>
|
|||
|
|
<li>• Keep cards flat and avoid glare</li>
|
|||
|
|
<li>• Capture the entire card including borders</li>
|
|||
|
|
<li>• Use high resolution images when possible</li>
|
|||
|
|
<li>• For foil cards, angle to reduce glare</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default Scanner;
|