From 8209566451bd420b4ba61f103eb700576f0092e3 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 22 Jul 2025 12:04:24 -0500 Subject: [PATCH] Implement wizard-style scanner with auto-scanning (Part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🧙‍♂️ Scanner Wizard Components: - ScanModeSelector: Choose between 4 scan modes with beautiful UI • Add to Database, Search Collections, Price Check, Deck Building • Hover effects and responsive grid layout • Pro tips section with usage guidance 📷 Auto-Scanning Camera: - AutoScanningCamera: 2-3 second stabilization with countdown - Simulated card detection every 2 seconds - 5-second minimum between scans to prevent rapid scanning - Queue counter (0/100) and duplicate detection - Manual scan fallback button 🎯 Scanning Toast: - Top-aligned toast notification matching screenshot style - Progress bar during stabilization countdown - Different states: scanning, processing, success, error - Smooth slide-down animation ✨ Features: - Queue management up to 100 cards - Recent scan deduplication (keeps last 10) - Visual feedback with scanning overlay - Image caching with canvas capture - Error handling and recovery Next: Card queue review stage with bulk operations --- src/components/scanner/AutoScanningCamera.tsx | 351 ++++++++++++++++++ src/components/scanner/ScanModeSelector.tsx | 89 +++++ src/components/scanner/ScanningToast.tsx | 84 +++++ src/index.css | 16 + 4 files changed, 540 insertions(+) create mode 100644 src/components/scanner/AutoScanningCamera.tsx create mode 100644 src/components/scanner/ScanModeSelector.tsx create mode 100644 src/components/scanner/ScanningToast.tsx diff --git a/src/components/scanner/AutoScanningCamera.tsx b/src/components/scanner/AutoScanningCamera.tsx new file mode 100644 index 0000000..0195d99 --- /dev/null +++ b/src/components/scanner/AutoScanningCamera.tsx @@ -0,0 +1,351 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import ScanningToast from './ScanningToast'; +import { aiCardOCR, type CardOCRResult } from '../../services/aiOcr'; + +interface ScannedCard { + id: string; + cardData: CardOCRResult; + imageDataUrl: string; + timestamp: number; + queuePosition: number; +} + +interface AutoScanningCameraProps { + onCardScanned: (card: ScannedCard) => void; + onError: (error: string) => void; + isActive: boolean; + maxQueueSize?: number; +} + +const AutoScanningCamera: React.FC = ({ + onCardScanned, + onError, + isActive, + maxQueueSize = 100 +}) => { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const streamRef = useRef(null); + const scanTimeoutRef = useRef(null); + const lastScanTimeRef = useRef(0); + + const [isStreaming, setIsStreaming] = useState(false); + const [scanState, setScanState] = useState<{ + isScanning: boolean; + isProcessing: boolean; + message: string; + countdown: number; + }>({ + isScanning: false, + isProcessing: false, + message: '', + countdown: 0 + }); + + const [queueCount, setQueueCount] = useState(0); + const [recentScans, setRecentScans] = useState>(new Set()); + + // Initialize camera + useEffect(() => { + if (isActive) { + startCamera(); + } else { + stopCamera(); + } + + return () => { + stopCamera(); + }; + }, [isActive]); + + // Auto-scan detection + useEffect(() => { + if (!isStreaming || !isActive) return; + + const detectCardStabilization = () => { + if (scanState.isProcessing || queueCount >= maxQueueSize) return; + + // Clear existing timeout + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + } + + // Start countdown + setScanState(prev => ({ + ...prev, + isScanning: true, + message: 'Card detected, stabilizing...', + countdown: 3 + })); + + let countdown = 3; + const countdownInterval = setInterval(() => { + countdown--; + setScanState(prev => ({ + ...prev, + countdown, + message: countdown > 0 ? `Scanning in ${countdown}...` : 'Scanning card...' + })); + + if (countdown <= 0) { + clearInterval(countdownInterval); + triggerScan(); + } + }, 1000); + + // Set timeout for actual scan + scanTimeoutRef.current = setTimeout(() => { + clearInterval(countdownInterval); + triggerScan(); + }, 3000); + }; + + // Simulate card detection (in real implementation, this would use computer vision) + const detectionInterval = setInterval(() => { + // Only trigger if enough time has passed since last scan (prevent rapid scanning) + const now = Date.now(); + if (now - lastScanTimeRef.current > 5000) { // 5 second minimum between scans + detectCardStabilization(); + } + }, 2000); + + return () => { + clearInterval(detectionInterval); + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + } + }; + }, [isStreaming, isActive, scanState.isProcessing, queueCount, maxQueueSize]); + + const startCamera = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1920 }, + height: { ideal: 1080 } + } + }); + + if (videoRef.current) { + videoRef.current.srcObject = stream; + streamRef.current = stream; + + videoRef.current.onloadedmetadata = () => { + videoRef.current?.play().then(() => { + setIsStreaming(true); + }); + }; + } + } catch (err: any) { + console.error('Camera error:', err); + onError(`Camera access failed: ${err.message}`); + } + }; + + const stopCamera = () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + setIsStreaming(false); + setScanState({ + isScanning: false, + isProcessing: false, + message: '', + countdown: 0 + }); + }; + + const captureImage = useCallback((): string | null => { + if (!videoRef.current || !canvasRef.current) return null; + + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + + if (!ctx) return null; + + // Set canvas dimensions to match video + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Draw current video frame to canvas + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + // Return image data URL + return canvas.toDataURL('image/jpeg', 0.8); + }, []); + + const triggerScan = useCallback(async () => { + if (scanState.isProcessing || queueCount >= maxQueueSize) return; + + setScanState(prev => ({ + ...prev, + isScanning: false, + isProcessing: true, + message: 'Processing card with AI...', + countdown: 0 + })); + + try { + const imageDataUrl = captureImage(); + if (!imageDataUrl) { + throw new Error('Failed to capture image'); + } + + // Process with AI OCR + const cardData = await aiCardOCR.analyzeCard(imageDataUrl); + + // Create unique ID for this scan + const cardId = `scan_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Check for recent duplicates (simple hash of card name) + const cardHash = (cardData.cardName || 'unknown').toLowerCase().replace(/\s+/g, ''); + const isRecentDuplicate = recentScans.has(cardHash); + + if (isRecentDuplicate) { + setScanState(prev => ({ + ...prev, + isProcessing: false, + message: `Duplicate detected: ${cardData.cardName || 'Unknown Card'}`, + })); + } else { + // Add to recent scans (keep last 10) + setRecentScans(prev => { + const newSet = new Set(prev); + newSet.add(cardHash); + if (newSet.size > 10) { + const firstKey = newSet.values().next().value; + newSet.delete(firstKey); + } + return newSet; + }); + + // Create scanned card + const scannedCard: ScannedCard = { + id: cardId, + cardData, + imageDataUrl, + timestamp: Date.now(), + queuePosition: queueCount + 1 + }; + + // Add to queue + onCardScanned(scannedCard); + setQueueCount(prev => prev + 1); + lastScanTimeRef.current = Date.now(); + + setScanState(prev => ({ + ...prev, + isProcessing: false, + message: `Added: ${cardData.cardName || 'Unknown Card'}`, + })); + } + + // Clear message after 2 seconds + setTimeout(() => { + setScanState(prev => ({ + ...prev, + message: '', + })); + }, 2000); + + } catch (error: any) { + console.error('Scan processing error:', error); + setScanState(prev => ({ + ...prev, + isProcessing: false, + message: 'Scan failed, try again', + })); + + setTimeout(() => { + setScanState(prev => ({ + ...prev, + message: '', + })); + }, 3000); + } + }, [captureImage, onCardScanned, queueCount, maxQueueSize, recentScans, scanState.isProcessing]); + + const manualScan = () => { + if (!scanState.isProcessing && queueCount < maxQueueSize) { + triggerScan(); + } + }; + + return ( +
+ {/* Scanning Toast */} + + + {/* Camera Preview */} +
+
+ ); +}; + +export default AutoScanningCamera; \ No newline at end of file diff --git a/src/components/scanner/ScanModeSelector.tsx b/src/components/scanner/ScanModeSelector.tsx new file mode 100644 index 0000000..065a30f --- /dev/null +++ b/src/components/scanner/ScanModeSelector.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +interface ScanMode { + id: 'add-to-database' | 'search-collections' | 'price-check' | 'deck-building'; + title: string; + description: string; + icon: string; + color: string; +} + +interface ScanModeSelectorProps { + onModeSelect: (mode: ScanMode['id']) => void; +} + +const ScanModeSelector: React.FC = ({ onModeSelect }) => { + const scanModes: ScanMode[] = [ + { + id: 'add-to-database', + title: 'Add Cards to Database', + description: 'Scan new cards and add them to your collections', + icon: '📚', + color: 'bg-blue-500 hover:bg-blue-600' + }, + { + id: 'search-collections', + title: 'Search Collections', + description: 'Find these cards in your existing collections', + icon: '🔍', + color: 'bg-green-500 hover:bg-green-600' + }, + { + id: 'price-check', + title: 'Price Check', + description: 'Get real-time pricing for your cards', + icon: '💰', + color: 'bg-yellow-500 hover:bg-yellow-600' + }, + { + id: 'deck-building', + title: 'Deck Building', + description: 'Scan cards directly into a deck with legality checks', + icon: '🎯', + color: 'bg-purple-500 hover:bg-purple-600' + } + ]; + + return ( +
+
+

Card Scanner

+

Choose your scanning mode to get started

+
+ +
+ {scanModes.map((mode) => ( + + ))} +
+ +
+
+

💡 Pro Tips:

+
    +
  • • Position cards clearly within the camera frame
  • +
  • • Scanner will auto-detect cards after 2-3 seconds
  • +
  • • You can scan up to 100 cards in a single session
  • +
  • • Duplicates are allowed and will be tracked separately
  • +
+
+
+
+ ); +}; + +export default ScanModeSelector; \ No newline at end of file diff --git a/src/components/scanner/ScanningToast.tsx b/src/components/scanner/ScanningToast.tsx new file mode 100644 index 0000000..a339562 --- /dev/null +++ b/src/components/scanner/ScanningToast.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +interface ScanningToastProps { + isVisible: boolean; + message: string; + progress?: number; // 0-100 for progress bar + type?: 'scanning' | 'processing' | 'success' | 'error'; +} + +const ScanningToast: React.FC = ({ + isVisible, + message, + progress, + type = 'scanning' +}) => { + if (!isVisible) return null; + + const getTypeStyles = () => { + switch (type) { + case 'scanning': + return 'bg-blue-600 text-white'; + case 'processing': + return 'bg-purple-600 text-white'; + case 'success': + return 'bg-green-600 text-white'; + case 'error': + return 'bg-red-600 text-white'; + default: + return 'bg-blue-600 text-white'; + } + }; + + const getIcon = () => { + switch (type) { + case 'scanning': + return '📷'; + case 'processing': + return '🤖'; + case 'success': + return '✅'; + case 'error': + return '❌'; + default: + return '📷'; + } + }; + + return ( +
+
+
+ {/* Icon */} +
{getIcon()}
+ + {/* Content */} +
+
{message}
+ + {/* Progress bar */} + {progress !== undefined && ( +
+
+
+
+
+ )} +
+ + {/* Spinner for scanning/processing */} + {(type === 'scanning' || type === 'processing') && ( +
+
+
+ )} +
+
+
+ ); +}; + +export default ScanningToast; \ No newline at end of file diff --git a/src/index.css b/src/index.css index eb4802f..28e1248 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,22 @@ @tailwind components; @tailwind utilities; +/* Custom animations */ +@keyframes slide-down { + from { + opacity: 0; + transform: translate(-50%, -20px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +.animate-slide-down { + animation: slide-down 0.3s ease-out; +} + /* Import custom card effects */ @import './styles/cardEffects.css';