deckhearth/pages/scanner.js
varutasu d9c51b8a78
refactor(scanner): extract useScannerQueue hook (page Brief 2) (#75)
Move queue state, bulk actions, in-flight guards, and destination list
loading into lib/use-scanner-queue.js. Scanner page keeps session prefs
and view markup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 21:28:24 -05:00

393 lines
16 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 { useEffect, useState } 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 {
DEFAULT_SCANNER_DESTINATION,
loadSavedScannerSession,
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 [showOCRSettings, setShowOCRSettings] = useState(false);
const [sessionDestination, setSessionDestination] = useState(
() => loadSavedScannerSession().destination
);
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
const queue = useScannerQueue({ user, sessionDestination, scanDefaults });
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults });
}, [sessionDestination, gameFilter, scanDefaults]);
const handleGameFilterChange = (nextFilter) => {
setGameFilter(nextFilter);
setSessionDestination((current) => {
if (!current || current.type === 'owned') return current;
return DEFAULT_SCANNER_DESTINATION;
});
};
const handleError = (error) => {
console.error('Scanner error:', error);
};
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">
<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={queue.collections}
decks={queue.decks}
disabled={queue.isProcessing}
/>
{queue.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"
>
{queue.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>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
<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={queue.handleCardScanned} onError={handleError} />
</div>
</div>
</div>
<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"
>
{queue.scannedCards.length} cards
</div>
{queue.scannedCards.length > 0 && (
<button
onClick={queue.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>
<div className="flex-1 overflow-y-auto">
{queue.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">
{queue.scannedCards.map((card) => (
<li key={card.id}>
<ScannedCardItem
card={card}
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)}
/>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
{queue.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)',
}}
>
<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)' }}
>
{queue.selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{queue.selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<div className="flex items-center gap-3">
<button
onClick={() => queue.handleBulkAction('owned')}
disabled={queue.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>
{queue.collections.length > 0 && (
<select
onChange={(e) => {
const collectionId = e.target.value;
e.target.value = '';
if (collectionId) {
queue.handleBulkAction('collection', collectionId);
}
}}
disabled={queue.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>
{queue.collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{queue.decks.length > 0 && (
<select
onChange={(e) => {
const deckId = e.target.value;
e.target.value = '';
if (deckId) {
queue.handleBulkAction('deck', deckId);
}
}}
disabled={queue.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>
{queue.decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<button
onClick={queue.clearSelection}
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>
)}
{queue.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={queue.newCollectionName}
onChange={(e) => queue.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') {
queue.createCollection();
}
}}
/>
<div className="flex gap-3">
<button
onClick={queue.createCollection}
disabled={!queue.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={() => queue.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>
)}
{showOCRSettings && <OCRSettings onClose={() => setShowOCRSettings(false)} />}
</div>
</Layout>
);
}