deckhearth/src/pages/Scanner.tsx

386 lines
16 KiB
TypeScript
Raw Normal View History

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;