✨ Features: - Camera OCR card scanning with Tesseract.js - Beautiful glowing card effects and animations - Intelligent card matching and recognition - Mobile-responsive design with Tailwind CSS 🏗️ Architecture: - Frontend: React TypeScript application - Backend: Vercel Functions (replacing FastAPI) - Database: JSON file with exported card data - Deployment: Single Vercel project 📁 Structure: - api/ - Vercel Functions backend endpoints - src/ - React frontend components and logic - src/data/cards.json - Card database (12 cards) - vercel.json - Optimized Vercel configuration 💰 Cost: /bin/zsh additional (uses existing Vercel Pro) 🚀 Ready for immediate Vercel deployment
386 lines
No EOL
16 KiB
TypeScript
386 lines
No EOL
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;
|