import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CameraScanner from '../components/CameraScanner'; import ScannerDestinationPicker from '../components/ScannerDestinationPicker'; import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem'; import OCRSettings from '../components/OCRSettings'; import { useAuth } from '../lib/use-auth'; import { useFocusTrap } from '../lib/use-focus-trap.js'; import { VOCAB } from '../lib/collection-vocabulary.js'; import { addScannedCardToCollection, addScannedCardToDeck, addScannedCardToOwned, createScannerCollection, fetchScannerCollections, fetchScannerDecks, routeScannedCardToDestination, } from '../lib/scanner-route-api.js'; import { DEFAULT_SCANNER_DESTINATION, destinationActionKey, loadSavedScannerSession, mergeScannedCardEntry, saveScannerSession, } from '../lib/scanner-session.js'; export default function Scanner() { const { user, loading: authLoading } = useAuth(); const router = useRouter(); const [scannedCards, setScannedCards] = useState([]); const [collections, setCollections] = useState([]); const [decks, setDecks] = useState([]); const [showCreateCollection, setShowCreateCollection] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const createCollectionDialogRef = useFocusTrap(showCreateCollection); const [showOCRSettings, setShowOCRSettings] = useState(false); // Bulk action states const [selectedCards, setSelectedCards] = useState(new Set()); const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck' const [bulkTarget, setBulkTarget] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const addingInFlightRef = useRef(new Set()); const [addingCardIds, setAddingCardIds] = useState(() => new Set()); const [sessionDestination, setSessionDestination] = useState( () => loadSavedScannerSession().destination ); const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter); const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults); const [autoRouteError, setAutoRouteError] = useState(null); // Redirect to login if not authenticated (wait for verify to finish) useEffect(() => { if (!authLoading && !user) { router.push('/login'); } }, [authLoading, user, router]); useEffect(() => { saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults }); }, [sessionDestination, gameFilter, scanDefaults]); useEffect(() => { if (!user) return; let cancelled = false; (async () => { try { const [loadedCollections, loadedDecks] = await Promise.all([ fetchScannerCollections(), fetchScannerDecks(), ]); if (cancelled) return; setCollections(loadedCollections); setDecks(loadedDecks); } catch (error) { console.error('Error loading scanner destinations:', error); } })(); return () => { cancelled = true; }; }, [user]); const handleGameFilterChange = (nextFilter) => { setGameFilter(nextFilter); setSessionDestination((current) => { if (!current || current.type === 'owned') return current; return DEFAULT_SCANNER_DESTINATION; }); }; const handleCardScanned = async (cardData) => { setAutoRouteError(null); const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry( scannedCards, cardData, scanDefaults ); setScannedCards(nextQueue); if (!sessionDestination) return; if (!cardEntry.databaseId) { setAutoRouteError(`"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.`); return; } try { if (!tryBeginAdding(cardEntry.id)) return; await routeScannedCardToDestination(cardEntry, sessionDestination); markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination)); } catch (error) { console.error('Auto-route failed:', error); setAutoRouteError(`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`); } finally { endAdding(cardEntry.id); } }; // Quantity management functions const incrementCardQuantity = (cardId) => { setScannedCards(prev => prev.map(card => card.id === cardId ? { ...card, quantity: (card.quantity || 1) + 1 } : card )); }; const decrementCardQuantity = (cardId) => { setScannedCards(prev => prev.map(card => card.id === cardId ? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) } : card )); }; const handleError = (error) => { console.error('Scanner error:', error); // You could show a toast notification here }; const syncAddingState = () => { setAddingCardIds(new Set(addingInFlightRef.current)); }; const tryBeginAdding = (cardId) => { if (addingInFlightRef.current.has(cardId)) return false; addingInFlightRef.current.add(cardId); syncAddingState(); return true; }; const endAdding = (cardId) => { addingInFlightRef.current.delete(cardId); syncAddingState(); }; // Individual card actions const addSingleCardToOwned = async (card) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToOwned(card); markCardAsProcessed(card.id, 'owned'); } catch (error) { console.error('Error adding card to owned:', error); } finally { endAdding(card.id); } }; const addSingleCardToCollection = async (card, collectionId) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToCollection(card, collectionId); markCardAsProcessed(card.id, 'collection'); } catch (error) { console.error('Error adding card to collection:', error); } finally { endAdding(card.id); } }; const addSingleCardToDeck = async (card, deckId) => { if (!tryBeginAdding(card.id)) return; try { await addScannedCardToDeck(card, deckId); markCardAsProcessed(card.id, 'deck'); } catch (error) { console.error('Error adding card to deck:', error); } finally { endAdding(card.id); } }; // Bulk actions const handleBulkAction = async (actionOverride, targetOverride) => { const action = actionOverride ?? bulkAction; const target = targetOverride ?? bulkTarget; if (!action || selectedCards.size === 0) return; setIsProcessing(true); try { const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id)); for (const card of cardsToProcess) { if (action === 'owned') { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToOwned(card); } finally { endAdding(card.id); } } else if (action === 'collection' && target) { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToCollection(card, target); } finally { endAdding(card.id); } } else if (action === 'deck' && target) { if (!tryBeginAdding(card.id)) continue; try { await addScannedCardToDeck(card, target); } finally { endAdding(card.id); } } else { continue; } markCardAsProcessed(card.id, action); } setSelectedCards(new Set()); setBulkAction(''); setBulkTarget(''); } catch (error) { console.error('Error processing bulk action:', error); } finally { setIsProcessing(false); } }; const markCardAsProcessed = (cardId, action) => { setScannedCards(prev => prev.map(card => card.id === cardId ? { ...card, processed: true, processedAction: action } : card )); }; const updateCardMetadata = (cardId, patch) => { setScannedCards((prev) => prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card)) ); }; const createCollection = async () => { if (!newCollectionName.trim()) return; try { const newCollection = await createScannerCollection(newCollectionName.trim()); setCollections((prev) => [newCollection, ...prev]); setBulkTarget(newCollection.id.toString()); setNewCollectionName(''); setShowCreateCollection(false); } catch (error) { console.error('Error creating collection:', error); } }; const clearScannedCards = () => { setScannedCards([]); setSelectedCards(new Set()); }; const removeScannedCard = (cardId) => { setScannedCards(prev => prev.filter(card => card.id !== cardId)); setSelectedCards(prev => { const newSet = new Set(prev); newSet.delete(cardId); return newSet; }); }; const toggleCardSelection = (cardId) => { setSelectedCards(prev => { const newSet = new Set(prev); if (newSet.has(cardId)) { newSet.delete(cardId); } else { newSet.add(cardId); } return newSet; }); }; if (authLoading) { return (
); } if (!user) { return
Redirecting to login...
; } return (
{/* Header */}

🃏 Card Scanner

Pick a destination once — every scan lands there until you change it

{autoRouteError && (
{autoRouteError}
)}

Defaults for new scans

Applied to each card when it enters the queue

{/* Main Content - Full Height */}
{/* Camera Scanner */}

Camera Scanner

{/* Scanned Cards Queue */}

Scanned Cards

{scannedCards.length} cards
{scannedCards.length > 0 && ( )}
{/* Scanned Cards Queue - Scrollable */}
{scannedCards.length === 0 ? (
No cards scanned yet
Start scanning to see cards here
) : (
    {scannedCards.map((card) => (
  • toggleCardSelection(card.id)} onIncrement={() => incrementCardQuantity(card.id)} onDecrement={() => decrementCardQuantity(card.id)} onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)} onMarkOwned={() => addSingleCardToOwned(card)} onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)} onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)} onRemove={() => removeScannedCard(card.id)} isAdding={addingCardIds.has(card.id)} />
  • ))}
)}
{/* Floating Bulk Actions Toolbar */} {selectedCards.size > 0 && (
{/* Selection Count */}
{selectedCards.size}
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
{/* Divider */}
{/* Quick Actions */}
{collections.length > 0 && ( )} {decks.length > 0 && ( )}
{/* Divider */}
{/* Clear Selection */}
)} {/* Bulk Actions Modal */} {/* This modal is no longer needed as bulk actions are in a floating toolbar */} {/* Create List Modal */} {showCreateCollection && (

Create New List

setNewCollectionName(e.target.value)} className="w-full px-4 py-2 rounded-lg border mb-4" style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }} onKeyPress={(e) => { if (e.key === 'Enter') { createCollection(); } }} />
)} {/* OCR Settings Modal */} {showOCRSettings && ( setShowOCRSettings(false)} /> )}
); }