deckhearth/pages/scanner.js
Randall Stillwell 470d373849 Normalize collector numbers in catalog match and harden scanner adds.
Share card-number normalization across reconcile and identify paths, retry set/name matches when OCR uses leading-zero collector numbers, and extend in-flight locks to all scanner destination actions with disabled Mark Owned feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:22:06 -05:00

823 lines
No EOL
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
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 system collections (like "All My Cards")
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 owned cards');
}
};
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 (
<Layout user={null}>
<div className="flex items-center justify-center min-h-[50vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--text-accent)' }} />
</div>
</Layout>
);
}
if (!user) {
return <div>Redirecting to login...</div>;
}
return (
<Layout user={user}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="px-6 pt-6 pb-4">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
🃏 Card Scanner
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Pick a destination once every scan lands there until you change it
</p>
</div>
<ScannerDestinationPicker
gameFilter={gameFilter}
onGameFilterChange={handleGameFilterChange}
destination={sessionDestination}
onDestinationChange={setSessionDestination}
collections={collections}
decks={decks}
disabled={isProcessing}
/>
{autoRouteError && (
<div
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--accent-flame)',
color: 'var(--text-primary)',
}}
role="alert"
>
{autoRouteError}
</div>
)}
<div
className="mx-6 mb-4 rounded-xl border p-4 flex flex-wrap items-end gap-4"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
Defaults for new scans
</h2>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Applied to each card when it enters the queue
</p>
</div>
<label className="flex flex-col gap-1 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Condition</span>
<select
value={scanDefaults.condition}
onChange={(e) => setScanDefaults((current) => ({ ...current, condition: e.target.value }))}
className="px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{CONDITION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm pb-2 cursor-pointer" style={{ color: 'var(--text-primary)' }}>
<input
type="checkbox"
checked={scanDefaults.isFoil}
onChange={(e) => setScanDefaults((current) => ({ ...current, isFoil: e.target.checked }))}
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
{/* Main Content - Full Height */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
{/* Camera Scanner */}
<div className="lg:col-span-3 flex flex-col">
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Camera Scanner
</h2>
<button
onClick={() => setShowOCRSettings(true)}
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)'
}}
>
OCR Settings
</button>
</div>
<div className="flex-1">
<CameraScanner
onCardScanned={handleCardScanned}
onError={handleError}
/>
</div>
</div>
</div>
{/* Scanned Cards Queue */}
<div className="lg:col-span-2 flex flex-col">
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Scanned Cards
</h2>
<div className="flex items-center gap-2">
<div
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
aria-atomic="true"
>
{scannedCards.length} cards
</div>
{scannedCards.length > 0 && (
<button
onClick={clearScannedCards}
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Clear All
</button>
)}
</div>
</div>
{/* Scanned Cards Queue - Scrollable */}
<div className="flex-1 overflow-y-auto">
{scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
<div className="font-medium">No cards scanned yet</div>
<div className="text-sm">Start scanning to see cards here</div>
</div>
) : (
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
{scannedCards.map((card) => (
<li key={card.id}>
<ScannedCardItem
card={card}
collections={collections}
decks={decks}
selected={selectedCards.has(card.id)}
onToggleSelect={() => 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)}
/>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
{/* Floating Bulk Actions Toolbar */}
{selectedCards.size > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
backdropFilter: 'blur(10px)'
}}>
{/* Selection Count */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: 'var(--accent-ember)' }}>
{selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Quick Actions */}
<div className="flex items-center gap-3">
<button
onClick={() => handleBulkAction('owned')}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
<span aria-hidden="true">💎 </span>
Mark Owned
</button>
{collections.length > 0 && (
<select
onChange={(e) => {
const collectionId = e.target.value;
e.target.value = '';
if (collectionId) {
handleBulkAction('collection', collectionId);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
aria-label="Add selected cards to collection"
>
<option value="">📚 Add to Collection</option>
{collections.map(collection => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => {
const deckId = e.target.value;
e.target.value = '';
if (deckId) {
handleBulkAction('deck', deckId);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
aria-label="Add selected cards to deck"
>
<option value="">🃏 Add to Deck</option>
{decks.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Clear Selection */}
<button
onClick={() => setSelectedCards(new Set())}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
aria-label="Clear selection"
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
)}
{/* Bulk Actions Modal */}
{/* This modal is no longer needed as bulk actions are in a floating toolbar */}
{/* Create Collection Modal */}
{showCreateCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
ref={createCollectionDialogRef}
className="rounded-xl p-6 max-w-md w-full mx-4"
style={{ backgroundColor: 'var(--bg-secondary)' }}
role="dialog"
aria-modal="true"
aria-labelledby="create-collection-title"
>
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection
</h3>
<label htmlFor="create-collection-name" className="sr-only">
Collection name
</label>
<input
id="create-collection-name"
type="text"
placeholder="Collection name..."
value={newCollectionName}
onChange={(e) => 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();
}
}}
/>
<div className="flex gap-3">
<button
onClick={createCollection}
disabled={!newCollectionName.trim()}
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Create
</button>
<button
onClick={() => setShowCreateCollection(false)}
className="flex-1 px-4 py-2 rounded-lg border font-medium"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* OCR Settings Modal */}
{showOCRSettings && (
<OCRSettings onClose={() => setShowOCRSettings(false)} />
)}
</div>
</Layout>
);
}