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 { ManaCost, ColorIdentity } from '../components/ManaSymbols'; import ManaSymbolSettings from '../components/ManaSymbolSettings'; import { useAuth } from '../lib/use-auth'; import { useFocusTrap } from '../lib/use-focus-trap.js'; import { VOCAB } from '../lib/collection-vocabulary.js'; const SESSION_STORAGE_KEY = 'deckhearth:scanner-session'; const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION }; const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false }; function loadSavedScannerSession() { if (typeof window === 'undefined') { return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; } try { const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY)); return { destination: saved?.destination || DEFAULT_DESTINATION, gameFilter: saved?.gameFilter || 'All', scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS, }; } catch { return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; } } function destinationActionKey(destination) { if (!destination) return 'pending'; return destination.type === 'owned' ? 'owned' : destination.type; } 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); // Mana symbol settings const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); // Redirect to login if not authenticated (wait for verify to finish) useEffect(() => { if (!authLoading && !user) { router.push('/login'); } }, [authLoading, user, router]); // Load collections and decks useEffect(() => { if (user) { loadCollections(); loadDecks(); } }, [user]); useEffect(() => { if (typeof window === 'undefined') return; localStorage.setItem( SESSION_STORAGE_KEY, JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults }) ); }, [sessionDestination, gameFilter, scanDefaults]); const handleGameFilterChange = (nextFilter) => { setGameFilter(nextFilter); setSessionDestination((current) => { if (!current || current.type === 'owned') return current; return DEFAULT_DESTINATION; }); }; const routeCardToDestination = async (card, destination) => { if (!destination || !card.databaseId) return false; if (destination.type === 'owned') { await addToOwnedCards(card); } else if (destination.type === 'collection') { await addToCollection(card, destination.id); } else if (destination.type === 'deck') { await addToDeck(card, destination.id); } else { return false; } return true; }; const loadCollections = async () => { try { const response = await fetch('/api/collections', { headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` } }); if (response.ok) { const data = await response.json(); // Filter out auto-sync system lists (not user-curated) const userCollections = data.filter(collection => !collection.is_system_collection); setCollections(userCollections); } } catch (error) { console.error('Error loading collections:', error); } }; const loadDecks = async () => { try { const response = await fetch('/api/decks', { headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` } }); if (response.ok) { const data = await response.json(); setDecks(data); } } catch (error) { console.error('Error loading decks:', error); } }; const handleCardScanned = async (cardData) => { setAutoRouteError(null); const existingCardIndex = scannedCards.findIndex((existing) => existing.name === cardData.name && existing.set === cardData.set && !existing.processed ); let cardEntry; if (existingCardIndex !== -1) { const existing = scannedCards[existingCardIndex]; cardEntry = { ...existing, quantity: (existing.quantity || 1) + 1, scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl, timestamp: new Date().toISOString(), }; setScannedCards((prev) => { const updated = [...prev]; updated[existingCardIndex] = cardEntry; return updated; }); } else { cardEntry = { ...cardData, id: Date.now() + Math.random(), name: cardData.name, set: cardData.set, quantity: 1, condition: scanDefaults.condition, isFoil: scanDefaults.isFoil, timestamp: new Date().toISOString(), processed: false, }; setScannedCards((prev) => [cardEntry, ...prev]); } 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 routeCardToDestination(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 addToOwnedCards(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 addToCollection(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 addToDeck(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 addToOwnedCards(card); } finally { endAdding(card.id); } } else if (action === 'collection' && target) { if (!tryBeginAdding(card.id)) continue; try { await addToCollection(card, target); } finally { endAdding(card.id); } } else if (action === 'deck' && target) { if (!tryBeginAdding(card.id)) continue; try { await addToDeck(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 buildCardPayload = (cardData) => { const payload = { cardId: cardData.databaseId, quantity: cardData.quantity || 1, condition: cardData.condition || 'NM', is_foil: Boolean(cardData.isFoil), }; if (cardData.scanImageUrl) { payload.scan_image_url = cardData.scanImageUrl; } return payload; }; // Helper functions for API calls const addToOwnedCards = async (cardData) => { const response = await fetch('/api/user-cards', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }, body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`); } }; const addToCollection = async (cardData, collectionId) => { const response = await fetch(`/api/collections/${collectionId}/cards`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { throw new Error('Failed to add to collection'); } }; const addToDeck = async (cardData, deckId) => { const response = await fetch(`/api/decks/${deckId}/cards`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }, body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { throw new Error('Failed to add to deck'); } }; const createCollection = async () => { if (!newCollectionName.trim()) return; try { const response = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify({ name: newCollectionName, description: 'Created from card scanner', is_public: false }) }); if (response.ok) { const newCollection = await response.json(); 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; }); }; const selectAllCards = () => { const unprocessedCards = scannedCards.filter(card => !card.processed); setSelectedCards(new Set(unprocessedCards.map(card => card.id))); }; const deselectAllCards = () => { setSelectedCards(new Set()); }; 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 Collection Modal */} {showCreateCollection && (

Create New Collection

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)} /> )}
); }