import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import ScannerCamera from '../components/scanner/ScannerCamera'; import ScannerCheckoutSheet from '../components/scanner/ScannerCheckoutSheet'; import ScannerResultPanel from '../components/scanner/ScannerResultPanel'; import ScannerHistoryStrip, { TAB_QUEUE, TAB_RECENT, } from '../components/scanner/ScannerHistoryStrip'; import ScannerTips from '../components/scanner/ScannerTips'; import ScannerToast from '../components/scanner/ScannerToast'; import { Modal, Button } from '../components/ui'; import GlassSurface from '../components/ui/GlassSurface.js'; import { useAuth } from '../lib/use-auth'; import { useScannerSession } from '../lib/use-scanner-session.js'; import { useScannerQueue } from '../lib/use-scanner-queue.js'; import { useCameraScanner } from '../lib/use-camera-scanner.js'; import { useScannerIdentification } from '../lib/use-scanner-identification.js'; import { runSequentialGalleryIdentify } from '../lib/scanner-batch-identify.js'; import { clearScannerCartStorage } from '../lib/scanner-session.js'; import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js'; const TOAST_DURATION_MS = 2500; export default function Scanner() { const { user, loading: authLoading } = useAuth(); const router = useRouter(); const [isDesktop, setIsDesktop] = useState(false); const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [isListPickerOpen, setIsListPickerOpen] = useState(false); const [showLeaveModal, setShowLeaveModal] = useState(false); const [listCommitError, setListCommitError] = useState(null); const [inspectorCommitError, setInspectorCommitError] = useState(null); const [focusedCardId, setFocusedCardId] = useState(null); const [stripActiveTab, setStripActiveTab] = useState(TAB_RECENT); const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(() => { if (typeof window === 'undefined') return false; return window.matchMedia('(max-width: 767px)').matches; }); const [pageToast, setPageToast] = useState({ message: '', visible: false, type: 'success' }); const [galleryBusy, setGalleryBusy] = useState(false); const [batchBusy, setBatchBusy] = useState(false); const [batchProgress, setBatchProgress] = useState(null); const verificationPausedRef = useRef(false); const autoDetectPausedRef = useRef(false); const prevCardCountRef = useRef(0); const scannedCardsRef = useRef([]); const toastTimerRef = useRef(null); const batchCancelRef = useRef(false); const deskGalleryInputRef = useRef(null); const batchInputRef = useRef(null); const disambiguationActiveRef = useRef(false); const { scanDefaults } = useScannerSession(); const queue = useScannerQueue({ user, scanDefaults, deckMode: null }); const onVerifyCardRef = useRef(null); const camera = useCameraScanner({ onError: (msg) => console.error('Camera error:', msg), onVerifyCard: (cardTracker) => onVerifyCardRef.current?.(cardTracker), verificationPausedRef, autoDetectPausedRef, }); const identification = useScannerIdentification({ onCardScanned: queue.handleCardScanned, onError: (msg) => console.error('Identification error:', msg), onTrackerComplete: camera.removeTrackedCard, onTrackerReset: camera.resetTrackedCard, videoRef: camera.videoRef, canvasRef: camera.canvasRef, onVerifyCardRef, verificationPausedRef, }); const unprocessedCount = queue.unprocessedCount; const focusedCard = queue.scannedCards.find((card) => card.id === focusedCardId) ?? null; useEffect(() => { scannedCardsRef.current = queue.scannedCards; }, [queue.scannedCards]); useEffect(() => { const mq = window.matchMedia('(min-width: 768px)'); const sync = () => setIsDesktop(mq.matches); sync(); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync); }, []); useEffect(() => { if (!authLoading && !user) { router.push(`/login?returnUrl=${encodeURIComponent('/scanner')}`); } }, [authLoading, user, router]); useEffect(() => { disambiguationActiveRef.current = Boolean(identification.disambiguation); }, [identification.disambiguation]); useEffect(() => { const mobileCheckoutPauses = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches && isCheckoutOpen; verificationPausedRef.current = mobileCheckoutPauses || isListPickerOpen || Boolean(identification.disambiguation); autoDetectPausedRef.current = isAutoDetectPaused; }, [ isCheckoutOpen, isListPickerOpen, isAutoDetectPaused, identification.disambiguation, ]); useEffect(() => { if (queue.scannedCards.length > prevCardCountRef.current) { const latestUnprocessed = queue.scannedCards.find((card) => !card.processed); if (latestUnprocessed) { // Auto-focus the newest unprocessed scan when the queue grows. // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional focus sync on enqueue setFocusedCardId(latestUnprocessed.id); } } prevCardCountRef.current = queue.scannedCards.length; }, [queue.scannedCards]); useEffect(() => { return () => clearTimeout(toastTimerRef.current); }, []); const showPageToast = useCallback((message, type = 'success') => { clearTimeout(toastTimerRef.current); setPageToast({ message, visible: true, type }); toastTimerRef.current = setTimeout(() => { setPageToast((current) => ({ ...current, visible: false })); }, TOAST_DURATION_MS); }, []); const handleLeaveConfirm = () => { clearScannerCartStorage(); queue.clearScannedCards(); setShowLeaveModal(false); router.back(); }; const handleListPick = async (collectionId) => { setListCommitError(null); const card = isDesktop ? focusedCard : null; const selectedIds = isDesktop ? card ? [card.id] : [] : queue.scannedCards .filter((entry) => queue.selectedCards.has(entry.id)) .map((entry) => entry.id); if (selectedIds.length === 0) return; if (isDesktop && card) { const success = await queue.addSingleCardToCollection(card, collectionId); if (!success) { setListCommitError('Could not add cards to the list. Try again.'); return; } setIsListPickerOpen(false); const remaining = queue.scannedCards.filter( (entry) => !entry.processed && entry.id !== card.id ); setFocusedCardId(remaining[0]?.id ?? null); return; } const successfulIds = await queue.commitSelectedToCollection(collectionId); if (!successfulIds?.length) { setListCommitError('Could not add cards to the list. Try again.'); return; } setIsListPickerOpen(false); }; const handleInspectorAddToOwned = async (card) => { setInspectorCommitError(null); const success = await queue.addSingleCardToOwned(card); if (!success) { setInspectorCommitError('Could not add card. Try again.'); return; } showPageToast(`Added to ${VOCAB.MY_COLLECTION}`); const remaining = queue.scannedCards.filter( (entry) => !entry.processed && entry.id !== card.id ); setFocusedCardId(remaining[0]?.id ?? null); }; const handleInspectorAddToList = () => { if (!focusedCard) return; setListCommitError(null); setIsListPickerOpen(true); }; const handleRescan = async (card) => { queue.updateCardMetadata(card.id, { processed: false, identifyFailed: false, identifyFailureReason: undefined, confidence: undefined, }); if (card.scanImageUrl) { try { const response = await fetch(card.scanImageUrl); const blob = await response.blob(); const file = new File([blob], 'rescan.jpg', { type: blob.type || 'image/jpeg' }); await identification.identifyFromGalleryFile(file); } catch (error) { showPageToast(error.message || 'Rescan failed', 'error'); } return; } showPageToast('Rescan from camera', 'info'); }; const enqueueFailedIdentify = useCallback( (file, error) => { const baseName = file?.name?.replace(/\.[^.]+$/, '') || 'Unknown image'; queue.handleCardScanned({ name: baseName, set: 'Batch scan', identifyFailed: true, identifyFailureReason: error?.message || 'Could not identify card from gallery image', }); }, [queue] ); const identifyGalleryFileOrThrow = useCallback( async (file) => { const countBefore = scannedCardsRef.current.length; await identification.identifyFromGalleryFile(file); await new Promise((resolve) => setTimeout(resolve, 50)); if (scannedCardsRef.current.length > countBefore) return; if (disambiguationActiveRef.current) return; throw new Error('Could not identify card from gallery image'); }, [identification] ); const handleDeskGalleryChange = async (event) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; setGalleryBusy(true); try { await identification.identifyFromGalleryFile(file); } finally { setGalleryBusy(false); } }; const handleBatchChange = async (event) => { const files = event.target.files; event.target.value = ''; if (!files?.length) return; batchCancelRef.current = false; setStripActiveTab(TAB_QUEUE); setBatchBusy(true); setBatchProgress({ active: true, current: 0, total: files.length }); try { await runSequentialGalleryIdentify(files, identifyGalleryFileOrThrow, { cancelRef: batchCancelRef, onProgress: ({ current, total }) => { setBatchProgress({ active: true, current, total, onCancel: () => { batchCancelRef.current = true; }, }); }, onFileError: ({ file, error }) => { enqueueFailedIdentify(file, error); }, }); } finally { setBatchBusy(false); setBatchProgress(null); batchCancelRef.current = false; } }; const handleClearAll = () => { if (batchProgress?.active) return; clearScannerCartStorage(); queue.clearScannedCards(); setFocusedCardId(null); }; const handleBack = () => { if (unprocessedCount > 0) { setShowLeaveModal(true); return; } router.back(); }; if (authLoading) { return (
); } if (!user) { return null; } const listPickerDescription = isDesktop ? `Add ${focusedCard?.name ?? 'this card'} to one of your lists.` : 'Add the selected cards to one of your lists.'; return (
{isDesktop && (

Card Scanner

Identify cards with your webcam and add them to {VOCAB.MY_COLLECTION}.

)}
setIsCheckoutOpen(true)} onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)} latestPeekCard={queue.scannedCards[0] ?? null} cartCount={unprocessedCount} isCheckoutOpen={isCheckoutOpen} verificationPausedRef={verificationPausedRef} /> {isDesktop && (
{camera.devicePickerMessage && (

{camera.devicePickerMessage}

)}
Auto-detect
)}
{isCheckoutOpen && ( setIsCheckoutOpen(false)} onOpenListPicker={() => setIsListPickerOpen(true)} trapActive={!isListPickerOpen} /> )}
{isDesktop && (
)}
{isDesktop && (
queue.commitSelectedToOwned()} onOpenListPicker={() => setIsListPickerOpen(true)} isProcessing={queue.isProcessing} />
)} setShowLeaveModal(false)} title="Leave scanner?" description={`${unprocessedCount} scanned ${unprocessedCount === 1 ? 'card' : 'cards'} haven't been added yet. Leaving will clear your cart.`} >
{ setIsListPickerOpen(false); setListCommitError(null); }} title="Choose a List" description={listPickerDescription} > {listCommitError && (
{listCommitError}
)}
    {queue.collections.map((collection) => (
  • ))} {queue.collections.length === 0 && (

    No lists yet. Create a list from the Lists page first.

    )}
); }