deckhearth/src/pages/Scanner.tsx
Randall Stillwell 6b81ed38b0 🚀 TCG Vault - Complete All-Vercel Setup
 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
2025-07-21 13:53:06 -05:00

386 lines
No EOL
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;