diff --git a/lib/use-scanner-queue.js b/lib/use-scanner-queue.js new file mode 100644 index 0000000..56d5f90 --- /dev/null +++ b/lib/use-scanner-queue.js @@ -0,0 +1,289 @@ +import { useEffect, useRef, useState } from 'react'; +import { + addScannedCardToCollection, + addScannedCardToDeck, + addScannedCardToOwned, + createScannerCollection, + fetchScannerCollections, + fetchScannerDecks, + routeScannedCardToDestination, +} from './scanner-route-api.js'; +import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js'; + +/** + * Scanned-card queue, bulk selection, in-flight guards, and destination lists. + */ +export function useScannerQueue({ user, sessionDestination, scanDefaults }) { + const [scannedCards, setScannedCards] = useState([]); + const [collections, setCollections] = useState([]); + const [decks, setDecks] = useState([]); + const [selectedCards, setSelectedCards] = useState(new Set()); + const [bulkAction, setBulkAction] = useState(''); + const [bulkTarget, setBulkTarget] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [autoRouteError, setAutoRouteError] = useState(null); + const [showCreateCollection, setShowCreateCollection] = useState(false); + const [newCollectionName, setNewCollectionName] = useState(''); + + const addingInFlightRef = useRef(new Set()); + const [addingCardIds, setAddingCardIds] = useState(() => new Set()); + + 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 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(); + }; + + const markCardAsProcessed = (cardId, action) => { + setScannedCards((prev) => + prev.map((card) => + card.id === cardId ? { ...card, processed: true, processedAction: action } : card + ) + ); + }; + + 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); + } + }; + + 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 updateCardMetadata = (cardId, patch) => { + setScannedCards((prev) => + prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card)) + ); + }; + + 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); + } + }; + + 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 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 next = new Set(prev); + next.delete(cardId); + return next; + }); + }; + + const toggleCardSelection = (cardId) => { + setSelectedCards((prev) => { + const next = new Set(prev); + if (next.has(cardId)) { + next.delete(cardId); + } else { + next.add(cardId); + } + return next; + }); + }; + + const clearSelection = () => { + setSelectedCards(new Set()); + }; + + return { + scannedCards, + collections, + decks, + selectedCards, + isProcessing, + addingCardIds, + autoRouteError, + showCreateCollection, + setShowCreateCollection, + newCollectionName, + setNewCollectionName, + handleCardScanned, + incrementCardQuantity, + decrementCardQuantity, + updateCardMetadata, + addSingleCardToOwned, + addSingleCardToCollection, + addSingleCardToDeck, + handleBulkAction, + createCollection, + clearScannedCards, + removeScannedCard, + toggleCardSelection, + clearSelection, + }; +} diff --git a/pages/scanner.js b/pages/scanner.js index 72d8c02..b053dc3 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CameraScanner from '../components/CameraScanner'; @@ -8,49 +8,26 @@ 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'; +import { useScannerQueue } from '../lib/use-scanner-queue.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) + const queue = useScannerQueue({ user, sessionDestination, scanDefaults }); + const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection); + useEffect(() => { if (!authLoading && !user) { router.push('/login'); @@ -61,30 +38,6 @@ export default function Scanner() { 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) => { @@ -93,210 +46,8 @@ export default function Scanner() { }); }; - 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) { @@ -316,7 +67,6 @@ export default function Scanner() { return (
- {/* Header */}

🃏 Card Scanner @@ -331,12 +81,12 @@ export default function Scanner() { onGameFilterChange={handleGameFilterChange} destination={sessionDestination} onDestinationChange={setSessionDestination} - collections={collections} - decks={decks} - disabled={isProcessing} + collections={queue.collections} + decks={queue.decks} + disabled={queue.isProcessing} /> - {autoRouteError && ( + {queue.autoRouteError && (
- {autoRouteError} + {queue.autoRouteError}
)} @@ -392,9 +142,7 @@ export default function Scanner() {

- {/* Main Content - Full Height */}
- {/* Camera Scanner */}
@@ -406,7 +154,7 @@ export default function Scanner() { className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80" style={{ borderColor: 'var(--border)', - color: 'var(--text-primary)' + color: 'var(--text-primary)', }} > ⚙️ OCR Settings @@ -414,15 +162,11 @@ export default function Scanner() {
- +
- {/* Scanned Cards Queue */}
@@ -436,15 +180,15 @@ export default function Scanner() { aria-live="polite" aria-atomic="true" > - {scannedCards.length} cards + {queue.scannedCards.length} cards
- {scannedCards.length > 0 && ( + {queue.scannedCards.length > 0 && (
- {/* Scanned Cards Queue - Scrollable */}
- {scannedCards.length === 0 ? ( + {queue.scannedCards.length === 0 ? (
No cards scanned yet
@@ -464,22 +207,22 @@ export default function Scanner() {
) : (
    - {scannedCards.map((card) => ( + {queue.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)} + collections={queue.collections} + decks={queue.decks} + selected={queue.selectedCards.has(card.id)} + onToggleSelect={() => queue.toggleCardSelection(card.id)} + onIncrement={() => queue.incrementCardQuantity(card.id)} + onDecrement={() => queue.decrementCardQuantity(card.id)} + onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)} + onMarkOwned={() => queue.addSingleCardToOwned(card)} + onAddToCollection={(collectionId) => queue.addSingleCardToCollection(card, collectionId)} + onAddToDeck={(deckId) => queue.addSingleCardToDeck(card, deckId)} + onRemove={() => queue.removeScannedCard(card.id)} + isAdding={queue.addingCardIds.has(card.id)} />
  • ))} @@ -490,8 +233,7 @@ export default function Scanner() {
- {/* Floating Bulk Actions Toolbar */} - {selectedCards.size > 0 && ( + {queue.selectedCards.size > 0 && (
- - {/* Selection Count */}
-
- {selectedCards.size} +
+ {queue.selectedCards.size}
- {selectedCards.size === 1 ? 'card selected' : 'cards selected'} + {queue.selectedCards.size === 1 ? 'card selected' : 'cards selected'}
- {/* Divider */}
- {/* Quick Actions */}
- {collections.length > 0 && ( + {queue.collections.length > 0 && ( )} - {decks.length > 0 && ( + {queue.decks.length > 0 && (