deckhearth/pages/scanner.js
varutasu 83d73eecaf
refactor(scanner): extract session and route API libs (page Brief 1) (#74)
Move scanner session persistence, queue merge helpers, and destination
routing fetch calls into lib/scanner-session.js and lib/scanner-route-api.js.
Load collections/decks on mount (were defined but never invoked).
Remove unused mana-symbol imports and dead select-all helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 16:15:27 -05:00

662 lines
No EOL
24 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 { 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 (
<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)'
}}
aria-label="Clear all scanned cards from queue"
>
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"
role="toolbar"
aria-label="Bulk actions for selected scanned cards"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
backdropFilter: 'blur(10px)',
}}
>
{/* Selection Count */}
<div className="flex items-center gap-2" aria-live="polite" aria-atomic="true">
<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>
{VOCAB.ADD_TO_MY_COLLECTION}
</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 list"
>
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</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 List 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 List
</h3>
<label htmlFor="create-collection-name" className="sr-only">
List name
</label>
<input
id="create-collection-name"
type="text"
placeholder="List 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>
);
}