feat(scanner): rebuild as mobile-first three-phase flow
Replace the desktop-first, everything-at-once scanner layout with a phased mobile-optimized experience: Setup → Scanning → Review. Phase 1 (Setup): destination picker, game filter, deck mode toggle, scan history (last 5 sessions). Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound feedback on card detection, torch/flash toggle, count pill, bottom-sheet disambiguation (replaces full-screen modal). Phase 3 (Review): card list with inline condition/foil/qty edits, batch confirm, 30-second undo, deck progress indicator. New features: - Deck mode (progress toward 40/60/99 card target) - Scan history (persisted to localStorage) - Sound feedback (Web Audio oscillator, configurable) - Offline queue (localStorage persistence + auto-retry on reconnect) - Camera flash/torch toggle - Batch ownership API (replaces N+1 per-card fetches) - Visibility pause (detection loop stops when tab is backgrounded) Convoy: scanner-rebuild Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
237870c17e
commit
cf9fea0726
18 changed files with 2165 additions and 48 deletions
63
.convoys/scanner-rebuild.md
Normal file
63
.convoys/scanner-rebuild.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Convoy: scanner-rebuild
|
||||
|
||||
**Status:** In Progress (auto-approved — overnight build)
|
||||
**Owner:** Agent
|
||||
**Created:** 2026-06-13
|
||||
|
||||
## Goal
|
||||
|
||||
Rebuild the card scanner from a desktop-first, configuration-heavy, everything-at-once layout into a mobile-first, three-phase flow optimized for rapid multi-card scanning.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### D1 — Three-phase flow
|
||||
|
||||
The scanner page becomes a state machine with three phases:
|
||||
- **Setup** — choose destination + game (one-time per session)
|
||||
- **Scanning** — full-screen camera with success toasts + count pill
|
||||
- **Review** — card list with per-card edits + batch confirm
|
||||
|
||||
### D2 — Mobile-first camera
|
||||
|
||||
Camera fills the viewport during scanning phase. No side panels, no scrolling to see results. Success feedback via overlay toasts + haptic vibration.
|
||||
|
||||
### D3 — Auto-everything during scan
|
||||
|
||||
- Cards auto-route to chosen destination immediately
|
||||
- Condition defaults NM, foil auto-detected from vision response
|
||||
- Game auto-detected from vision (no pre-filter needed)
|
||||
- Duplicates auto-increment quantity
|
||||
|
||||
### D4 — Deck Mode
|
||||
|
||||
Toggle in setup phase. Shows progress toward format-aware deck size (60/99/40). Auto-suggests stop when target reached.
|
||||
|
||||
### D5 — Disambiguation as bottom sheet
|
||||
|
||||
Replace full-screen modal with a slide-up bottom sheet. One tap to pick, then immediately resume scanning.
|
||||
|
||||
### D6 — New features
|
||||
|
||||
- **Scan history** — last 5 sessions persisted in localStorage
|
||||
- **Sound feedback** — subtle blip on successful scan (configurable)
|
||||
- **Offline queue** — if network drops, queue identification calls and retry
|
||||
- **Camera flash toggle** — torch mode for foil detection in dim lighting
|
||||
|
||||
## File ownership
|
||||
|
||||
| Workstream | Files | Agent |
|
||||
|---|---|---|
|
||||
| Core orchestrator | `pages/scanner.js`, `lib/use-scanner-session.js` | A |
|
||||
| Camera phase | `components/scanner/ScannerCamera.js`, `ScannerToast.js`, `ScannerCountPill.js`, `lib/use-camera-scanner.js`, `lib/use-scanner-sound.js`, `lib/use-scanner-flash.js` | B |
|
||||
| Setup + Review | `components/scanner/ScannerSetup.js`, `ScannerReview.js`, `ScannerDisambiguation.js`, `DeckModeIndicator.js` | C |
|
||||
| Queue + API | `lib/use-scanner-queue.js`, `lib/use-scanner-offline.js`, `pages/api/cards/batch-ownership.js` | D |
|
||||
|
||||
## Risks
|
||||
|
||||
- R1: Parallel agents may produce interface mismatches → mitigated by specifying contracts in each agent's prompt
|
||||
- R2: Existing hooks (`use-scanner-identification.js`, `scanner-card-identify.js`, `scanner-card-detection.js`) are not being rewritten — the rebuild layers on top of them
|
||||
- R3: Visual regression risk — existing smoke tests may break → handled in integration pass
|
||||
|
||||
## Human gates bypassed
|
||||
|
||||
Per user instruction (overnight build, 2026-06-13), all architect/reviewer gates are bypassed for this convoy. The user will review the final output in the morning.
|
||||
51
components/scanner/DeckModeIndicator.js
Normal file
51
components/scanner/DeckModeIndicator.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export default function DeckModeIndicator({ current, target, game }) {
|
||||
const progress = target > 0 ? Math.min(current / target, 1) : 0;
|
||||
const isComplete = current >= target;
|
||||
|
||||
return (
|
||||
<div className="mt-2 px-1">
|
||||
{/* Progress track */}
|
||||
<div
|
||||
className="w-full rounded-full overflow-hidden"
|
||||
style={{
|
||||
height: 6,
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
}}
|
||||
role="progressbar"
|
||||
aria-valuenow={current}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={target}
|
||||
aria-label={`Deck progress: ${current} of ${target} cards`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: isComplete
|
||||
? 'var(--accent-gold)'
|
||||
: 'var(--accent-ember)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: isComplete ? 'var(--accent-gold)' : 'var(--text-secondary)',
|
||||
textShadow: isComplete ? '0 0 8px var(--accent-gold)' : 'none',
|
||||
}}
|
||||
>
|
||||
{isComplete ? 'Deck complete!' : `${current}/${target} cards`}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{game}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
components/scanner/ReviewCardItem.js
Normal file
246
components/scanner/ReviewCardItem.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||
import { formatProcessedDestination } from '../../lib/collection-vocabulary.js';
|
||||
|
||||
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
||||
|
||||
export default function ReviewCardItem({
|
||||
card,
|
||||
collections,
|
||||
decks,
|
||||
onIncrement,
|
||||
onDecrement,
|
||||
onUpdateMetadata,
|
||||
onRemove,
|
||||
onUndo,
|
||||
isAdding = false,
|
||||
sessionDestination,
|
||||
}) {
|
||||
const destinationLabel = card.processedAction
|
||||
? formatProcessedDestination(card.processedAction)
|
||||
: sessionDestination?.label ?? 'My Collection';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
|
||||
style={{
|
||||
borderLeft: !card.processed ? '3px solid var(--accent-ember)' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden">
|
||||
{card.image_url ? (
|
||||
<img
|
||||
src={card.image_url}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center rounded-lg text-xs"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card info + controls */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{card.processed ? (
|
||||
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--accent-flame)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<h3
|
||||
className="font-semibold text-sm truncate"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{card.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove button (unprocessed only) */}
|
||||
{!card.processed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Remove ${card.name} from scan queue`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{card.processed ? (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Added to {destinationLabel}
|
||||
</span>
|
||||
{onUndo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
className="text-xs font-medium underline transition-opacity duration-150 hover:opacity-70"
|
||||
style={{ color: 'var(--accent-ember)', minHeight: '44px', display: 'inline-flex', alignItems: 'center' }}
|
||||
aria-label={`Undo adding ${card.name}`}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Metadata summary line */}
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
|
||||
</p>
|
||||
|
||||
{/* Inline edit controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-2">
|
||||
{/* Condition */}
|
||||
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="sr-only">Condition for {card.name}</span>
|
||||
<select
|
||||
value={card.condition || 'NM'}
|
||||
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
|
||||
className="px-2 py-1.5 rounded-lg text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
aria-label={`Condition for ${card.name}`}
|
||||
>
|
||||
{CONDITION_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Foil toggle */}
|
||||
<label
|
||||
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
|
||||
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(card.isFoil)}
|
||||
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: 'var(--accent-ember)' }}
|
||||
/>
|
||||
<span>Foil</span>
|
||||
</label>
|
||||
|
||||
{/* Quantity ± */}
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecrement}
|
||||
disabled={isAdding || (card.quantity || 1) <= 1}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
aria-label={`Decrease quantity of ${card.name}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span
|
||||
className="min-w-[1.5rem] text-center text-sm font-medium"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
aria-live="polite"
|
||||
aria-label={`Quantity: ${card.quantity || 1}`}
|
||||
>
|
||||
{card.quantity || 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIncrement}
|
||||
disabled={isAdding}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-ember)',
|
||||
color: '#ffffff',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
aria-label={`Increase quantity of ${card.name}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination override */}
|
||||
{(collections.length > 0 || decks.length > 0) && (
|
||||
<div className="mt-2">
|
||||
<select
|
||||
value={card.destinationOverride || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val) {
|
||||
onUpdateMetadata({ destinationOverride: null });
|
||||
} else {
|
||||
onUpdateMetadata({ destinationOverride: val });
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
aria-label={`Change destination for ${card.name}`}
|
||||
>
|
||||
<option value="">
|
||||
{sessionDestination ? `Default (${sessionDestination.label})` : 'Change destination'}
|
||||
</option>
|
||||
{collections.map((c) => (
|
||||
<option key={`col-${c.id}`} value={`collection:${c.id}`}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
{decks.map((d) => (
|
||||
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
components/scanner/ScannerCamera.js
Normal file
294
components/scanner/ScannerCamera.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ScannerToast from './ScannerToast.js';
|
||||
import ScannerCountPill from './ScannerCountPill.js';
|
||||
import DeckModeIndicator from './DeckModeIndicator.js';
|
||||
import ScannerDisambiguation from './ScannerDisambiguation.js';
|
||||
import { useScannerSound } from '../../lib/use-scanner-sound.js';
|
||||
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
|
||||
|
||||
const TOAST_DURATION_MS = 2500;
|
||||
|
||||
function overlayBorderColor(status) {
|
||||
if (status === 'scanned') return 'var(--color-info, #3B82F6)';
|
||||
return 'var(--color-success, #10B981)';
|
||||
}
|
||||
|
||||
function overlayGlowColor(status) {
|
||||
if (status === 'scanned') return 'rgba(59, 130, 246, 0.31)';
|
||||
return 'rgba(16, 185, 129, 0.31)';
|
||||
}
|
||||
|
||||
export default function ScannerCamera({
|
||||
queue,
|
||||
camera,
|
||||
identification,
|
||||
deckMode,
|
||||
onReview,
|
||||
onStopSession,
|
||||
sessionDestination,
|
||||
}) {
|
||||
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
|
||||
const toastTimerRef = useRef(null);
|
||||
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
canvasRef,
|
||||
detectionCanvasRef,
|
||||
isStreaming,
|
||||
isDetecting,
|
||||
trackedCards,
|
||||
videoMetrics,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
streamRef,
|
||||
} = camera;
|
||||
|
||||
const sound = useScannerSound();
|
||||
// Requires useCameraScanner to expose streamRef — gracefully degrades to flash-unsupported if absent
|
||||
const flash = useScannerFlash(streamRef);
|
||||
|
||||
// Auto-start camera on mount
|
||||
useEffect(() => {
|
||||
startCamera();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
|
||||
}, []);
|
||||
|
||||
// React to newly scanned cards
|
||||
const currentCount = queue.scannedCards?.length ?? 0;
|
||||
useEffect(() => {
|
||||
if (currentCount > prevCountRef.current) {
|
||||
const newest = queue.scannedCards[0];
|
||||
const cardName = newest?.name || newest?.card?.name || 'Card scanned';
|
||||
|
||||
navigator.vibrate?.(50);
|
||||
sound.playSuccess();
|
||||
|
||||
clearTimeout(toastTimerRef.current);
|
||||
setToast({ message: cardName, visible: true, type: 'success' });
|
||||
toastTimerRef.current = setTimeout(() => {
|
||||
setToast((t) => ({ ...t, visible: false }));
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
prevCountRef.current = currentCount;
|
||||
}, [currentCount]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only reacts to count
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(toastTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stopCamera();
|
||||
onStopSession?.();
|
||||
}, [stopCamera, onStopSession]);
|
||||
|
||||
const foundCards = trackedCards.filter(
|
||||
(c) => c.status === 'confirmed' || c.status === 'scanned'
|
||||
);
|
||||
|
||||
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full gap-3">
|
||||
{/* Camera viewport */}
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-2xl flex-1"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
aspectRatio: '4 / 3',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
}}
|
||||
>
|
||||
{/* Video */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ display: isStreaming ? 'block' : 'none' }}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
aria-label="Card scanner camera feed"
|
||||
/>
|
||||
|
||||
{/* Bounding box overlays */}
|
||||
{isStreaming &&
|
||||
hasMetrics &&
|
||||
foundCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="absolute rounded-lg transition-all duration-200 pointer-events-none"
|
||||
style={{
|
||||
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
|
||||
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
|
||||
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
|
||||
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
|
||||
borderWidth: 3,
|
||||
borderStyle: 'solid',
|
||||
borderColor: overlayBorderColor(card.status),
|
||||
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-7 left-0 px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-md whitespace-nowrap"
|
||||
style={{ backgroundColor: overlayBorderColor(card.status) }}
|
||||
>
|
||||
{card.status === 'scanned' ? 'Scanned' : 'Found'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* LIVE indicator */}
|
||||
{isStreaming && (
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--color-error, #EF4444)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
<ScannerToast
|
||||
message={toast.message}
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
/>
|
||||
|
||||
{/* Bottom controls */}
|
||||
{isStreaming && (
|
||||
<div className="absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-4 px-4">
|
||||
{/* Sound toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sound.setEnabled((v) => !v)}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
}}
|
||||
aria-label={sound.enabled ? 'Mute scan sounds' : 'Unmute scan sounds'}
|
||||
aria-pressed={sound.enabled}
|
||||
>
|
||||
{sound.enabled ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stop button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center shadow-2xl transition-transform active:scale-95"
|
||||
style={{
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||
border: '3px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
aria-label="Stop scanning"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-sm"
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Flash toggle */}
|
||||
{flash.flashSupported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={flash.toggleFlash}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
|
||||
style={{
|
||||
backgroundColor: flash.flashOn
|
||||
? 'rgba(255, 171, 64, 0.85)'
|
||||
: 'rgba(0, 0, 0, 0.55)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
}}
|
||||
aria-label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
|
||||
aria-pressed={flash.flashOn}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state (before stream starts) */}
|
||||
{!isStreaming && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
|
||||
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Starting camera…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden canvases for detection */}
|
||||
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
|
||||
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
|
||||
|
||||
{/* Count pill */}
|
||||
<ScannerCountPill count={currentCount} onReview={onReview} />
|
||||
|
||||
{/* Deck mode progress */}
|
||||
{deckMode && (
|
||||
<DeckModeIndicator
|
||||
current={currentCount}
|
||||
target={deckMode.targetSize}
|
||||
game={deckMode.game}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disambiguation overlay */}
|
||||
{identification.disambiguation && (
|
||||
<ScannerDisambiguation
|
||||
disambiguation={identification.disambiguation}
|
||||
submittingReview={identification.submittingReview}
|
||||
onPick={identification.handleDisambiguationPick}
|
||||
onNotInCatalog={identification.handleNotInCatalog}
|
||||
onCancel={identification.cancelDisambiguation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
components/scanner/ScannerCountPill.js
Normal file
32
components/scanner/ScannerCountPill.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export default function ScannerCountPill({ count, onReview }) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl px-4 py-3 flex items-center justify-between">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{count} {count === 1 ? 'card' : 'cards'} scanned
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReview}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded px-2 py-1"
|
||||
style={{
|
||||
color: 'var(--accent-ember)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
'--tw-ring-offset-color': 'transparent',
|
||||
minHeight: 44,
|
||||
minWidth: 44,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
aria-label={`Review ${count} scanned cards`}
|
||||
>
|
||||
Review →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
components/scanner/ScannerDisambiguation.js
Normal file
216
components/scanner/ScannerDisambiguation.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useFocusTrap } from '../../lib/use-focus-trap';
|
||||
|
||||
export default function ScannerDisambiguation({
|
||||
disambiguation,
|
||||
submittingReview,
|
||||
onPick,
|
||||
onNotInCatalog,
|
||||
onCancel,
|
||||
}) {
|
||||
const sheetRef = useFocusTrap(Boolean(disambiguation));
|
||||
const backdropRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disambiguation) return undefined;
|
||||
|
||||
const backdrop = backdropRef.current;
|
||||
const sheet = sheetRef.current;
|
||||
if (!backdrop || !sheet) return undefined;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.dataset.visible = 'true';
|
||||
sheet.dataset.visible = 'true';
|
||||
});
|
||||
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [disambiguation, onCancel, sheetRef]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
},
|
||||
[onCancel]
|
||||
);
|
||||
|
||||
if (!disambiguation) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-50 disambiguation-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="disambiguation-sheet-title"
|
||||
>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden disambiguation-sheet"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div
|
||||
className="w-10 h-1 rounded-full"
|
||||
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6 pb-2">
|
||||
<h3
|
||||
id="disambiguation-sheet-title"
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Which card is this?
|
||||
</h3>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
||||
</p>
|
||||
|
||||
{disambiguation.visionHint && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mt-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--accent-ember)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Vision detected: {disambiguation.visionHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal candidate scroll */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 sm:px-6 py-3 snap-x snap-mandatory"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Card candidates"
|
||||
>
|
||||
{disambiguation.candidates.map((candidate) => (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate.id}
|
||||
onClick={() => onPick(candidate)}
|
||||
className="flex-shrink-0 w-32 rounded-xl overflow-hidden text-left transition-all duration-150 snap-start focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '2px solid transparent',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
'--tw-ring-offset-color': 'transparent',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
|
||||
>
|
||||
{candidate.image_url ? (
|
||||
<img
|
||||
src={candidate.image_url}
|
||||
alt=""
|
||||
className="w-full aspect-[5/7] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full aspect-[5/7] flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
>
|
||||
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
<div className="font-medium text-xs truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{candidate.name}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{[candidate.set_name, candidate.card_number].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-4 sm:px-6 pb-4" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNotInCatalog}
|
||||
disabled={submittingReview}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
{submittingReview ? 'Submitting...' : 'Not listed \u2014 send for review'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="w-full py-3 mt-2 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-70"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.disambiguation-backdrop {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
|
||||
-webkit-backdrop-filter 300ms ease-out;
|
||||
}
|
||||
.disambiguation-backdrop[data-visible='true'] {
|
||||
background: var(--modal-scrim);
|
||||
backdrop-filter: blur(var(--glass-blur-high));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-high));
|
||||
}
|
||||
.disambiguation-sheet {
|
||||
max-height: 60vh;
|
||||
transform: translateY(100%);
|
||||
transition: transform 300ms ease-out;
|
||||
}
|
||||
.disambiguation-sheet[data-visible='true'] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
div[role='listbox']::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
components/scanner/ScannerReview.js
Normal file
192
components/scanner/ScannerReview.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Button } from '../ui';
|
||||
import ReviewCardItem from './ReviewCardItem';
|
||||
|
||||
export default function ScannerReview({
|
||||
queue,
|
||||
collections,
|
||||
decks,
|
||||
deckMode,
|
||||
onContinueScanning,
|
||||
onNewSession,
|
||||
sessionDestination,
|
||||
}) {
|
||||
const { scannedCards } = queue;
|
||||
|
||||
const unprocessedCards = useMemo(
|
||||
() => scannedCards.filter((c) => !c.processed),
|
||||
[scannedCards]
|
||||
);
|
||||
|
||||
const processedCards = useMemo(
|
||||
() => scannedCards.filter((c) => c.processed),
|
||||
[scannedCards]
|
||||
);
|
||||
|
||||
const deckProgress = deckMode
|
||||
? { current: scannedCards.length, target: deckMode.targetSize }
|
||||
: null;
|
||||
|
||||
const handleConfirmAll = () => {
|
||||
if (unprocessedCards.length === 0) return;
|
||||
const unprocessedIds = unprocessedCards.map((c) => c.id);
|
||||
unprocessedIds.forEach((id) => queue.toggleCardSelection(id));
|
||||
setTimeout(() => queue.handleBulkAction('owned'), 0);
|
||||
};
|
||||
|
||||
if (scannedCards.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
No cards to review
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Scan some cards first, then come back to review and confirm them.
|
||||
</p>
|
||||
<Button variant="primary" size="lg" onClick={onContinueScanning}>
|
||||
Start Scanning
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Review{' '}
|
||||
<span className="text-sm font-normal" style={{ color: 'var(--text-secondary)' }}>
|
||||
({scannedCards.length} {scannedCards.length === 1 ? 'card' : 'cards'})
|
||||
</span>
|
||||
</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onContinueScanning}>
|
||||
Continue
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Deck progress (if applicable) */}
|
||||
{deckProgress && (
|
||||
<div className="px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
Deck progress
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{deckProgress.current} / {deckProgress.target}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-2 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
role="progressbar"
|
||||
aria-valuenow={deckProgress.current}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={deckProgress.target}
|
||||
aria-label="Deck building progress"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (deckProgress.current / deckProgress.target) * 100)}%`,
|
||||
background: 'linear-gradient(90deg, var(--accent-ember), var(--accent-flame))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable card list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4" style={{ paddingBottom: '10rem' }}>
|
||||
{/* Unprocessed cards first */}
|
||||
{unprocessedCards.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Pending ({unprocessedCards.length})
|
||||
</h3>
|
||||
{unprocessedCards.map((card) => (
|
||||
<ReviewCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
collections={collections}
|
||||
decks={decks}
|
||||
onIncrement={() => queue.incrementCardQuantity(card.id)}
|
||||
onDecrement={() => queue.decrementCardQuantity(card.id)}
|
||||
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
|
||||
onRemove={() => queue.removeScannedCard(card.id)}
|
||||
isAdding={false}
|
||||
sessionDestination={sessionDestination}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processed cards */}
|
||||
{processedCards.length > 0 && (
|
||||
<div className={unprocessedCards.length > 0 ? 'mt-4' : ''}>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Confirmed ({processedCards.length})
|
||||
</h3>
|
||||
{processedCards.map((card) => (
|
||||
<ReviewCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
collections={collections}
|
||||
decks={decks}
|
||||
onIncrement={() => {}}
|
||||
onDecrement={() => {}}
|
||||
onUpdateMetadata={() => {}}
|
||||
onRemove={() => {}}
|
||||
onUndo={() => queue.updateCardMetadata(card.id, { processed: false, processedAction: null })}
|
||||
isAdding={false}
|
||||
sessionDestination={sessionDestination}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky bottom action bar */}
|
||||
<div
|
||||
className="sticky bottom-0 left-0 right-0 px-4 sm:px-6 py-4 glass-panel-strong"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleConfirmAll}
|
||||
disabled={unprocessedCards.length === 0}
|
||||
loading={queue.isProcessing}
|
||||
>
|
||||
{unprocessedCards.length === 0
|
||||
? 'All cards confirmed'
|
||||
: `Confirm All (${unprocessedCards.length})`}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSession}
|
||||
className="w-full mt-2 py-2 text-sm font-medium text-center transition-opacity duration-150 hover:opacity-70"
|
||||
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
|
||||
>
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
379
components/scanner/ScannerSetup.js
Normal file
379
components/scanner/ScannerSetup.js
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { useState, useId } from 'react';
|
||||
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
|
||||
import { Button } from '../ui';
|
||||
|
||||
const GAMES = [
|
||||
{ value: 'All', label: 'Auto-detect' },
|
||||
{ value: 'mtg', label: 'Magic' },
|
||||
{ value: 'pokemon', label: 'Pokémon' },
|
||||
{ value: 'lorcana', label: 'Lorcana' },
|
||||
];
|
||||
|
||||
const DECK_SIZES = [
|
||||
{ value: 40, label: '40' },
|
||||
{ value: 60, label: '60' },
|
||||
{ value: 99, label: '99' },
|
||||
];
|
||||
|
||||
const DESTINATION_TYPES = [
|
||||
{ value: 'owned', label: VOCAB.MY_COLLECTION },
|
||||
{ value: 'collection', label: VOCAB.LIST },
|
||||
{ value: 'deck', label: 'Deck' },
|
||||
];
|
||||
|
||||
function ToggleGroup({ options, value, onChange, name, className = '' }) {
|
||||
return (
|
||||
<div className={`inline-flex rounded-xl overflow-hidden ${className}`} role="radiogroup" aria-label={name}>
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className="px-4 py-2.5 text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: selected ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||
color: selected ? '#ffffff' : 'var(--text-secondary)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanHistoryList({ history }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
if (!history || history.length === 0) return null;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown date';
|
||||
}
|
||||
};
|
||||
|
||||
const destinationLabel = (dest) => {
|
||||
if (!dest) return 'Unknown';
|
||||
if (dest.type === 'owned') return VOCAB.MY_COLLECTION;
|
||||
return dest.label || dest.type;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
className="flex items-center gap-2 text-sm font-medium w-full py-2 focus:outline-none focus-visible:ring-2 rounded-lg px-1 min-h-[44px]"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-200"
|
||||
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Recent Sessions
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<ul id={panelId} className="mt-2 space-y-2">
|
||||
{history.map((entry, i) => (
|
||||
<li
|
||||
key={entry.date || i}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span style={{ color: 'var(--text-primary)' }}>
|
||||
{entry.cardCount} {entry.cardCount === 1 ? 'card' : 'cards'}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{destinationLabel(entry.destination)}
|
||||
{entry.game && entry.game !== 'All' ? ` · ${entry.game}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatDate(entry.date)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScannerSetup({
|
||||
sessionDestination,
|
||||
onDestinationChange,
|
||||
gameFilter,
|
||||
onGameFilterChange,
|
||||
collections,
|
||||
decks,
|
||||
deckMode,
|
||||
onDeckModeChange,
|
||||
onStartScanning,
|
||||
scanHistory,
|
||||
}) {
|
||||
const destSelectId = useId();
|
||||
const gameGroupId = useId();
|
||||
const deckModeId = useId();
|
||||
const deckSizeId = useId();
|
||||
|
||||
const destinationType = sessionDestination?.type ?? 'owned';
|
||||
|
||||
const handleDestinationTypeChange = (type) => {
|
||||
if (type === 'owned') {
|
||||
onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION });
|
||||
} else if (type === 'collection') {
|
||||
const first = collections?.[0];
|
||||
onDestinationChange(
|
||||
first
|
||||
? { type: 'collection', id: first.id, label: collectionDisplayName(first) }
|
||||
: { type: 'collection', id: null, label: VOCAB.LIST }
|
||||
);
|
||||
} else if (type === 'deck') {
|
||||
const first = decks?.[0];
|
||||
onDestinationChange(
|
||||
first
|
||||
? { type: 'deck', id: first.id, label: first.name }
|
||||
: { type: 'deck', id: null, label: 'Deck' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleListSelect = (e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
const found = collections.find((c) => c.id === id);
|
||||
if (found) {
|
||||
onDestinationChange({
|
||||
type: 'collection',
|
||||
id: found.id,
|
||||
label: collectionDisplayName(found),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeckSelect = (e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
const found = decks.find((d) => d.id === id);
|
||||
if (found) {
|
||||
onDestinationChange({ type: 'deck', id: found.id, label: found.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeckModeToggle = () => {
|
||||
if (deckMode) {
|
||||
onDeckModeChange(null);
|
||||
} else {
|
||||
onDeckModeChange({ game: gameFilter, targetSize: 60 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeckSizeChange = (size) => {
|
||||
onDeckModeChange({ ...deckMode, targetSize: size });
|
||||
};
|
||||
|
||||
const selectStyle = {
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: 'var(--border)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 max-w-lg mx-auto">
|
||||
<div className="glass-panel rounded-2xl p-5 sm:p-6 space-y-6">
|
||||
<header>
|
||||
<h1
|
||||
className="text-xl sm:text-2xl font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Card Scanner
|
||||
</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Choose where scanned cards will go
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Destination picker */}
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Destination
|
||||
</legend>
|
||||
<ToggleGroup
|
||||
options={DESTINATION_TYPES}
|
||||
value={destinationType}
|
||||
onChange={handleDestinationTypeChange}
|
||||
name="Scan destination"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{destinationType === 'collection' && (
|
||||
<div className="mt-3">
|
||||
<label htmlFor={destSelectId} className="sr-only">
|
||||
Choose a list
|
||||
</label>
|
||||
<select
|
||||
id={destSelectId}
|
||||
value={sessionDestination?.id ?? ''}
|
||||
onChange={handleListSelect}
|
||||
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
|
||||
style={{
|
||||
...selectStyle,
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
}}
|
||||
>
|
||||
{(!collections || collections.length === 0) && (
|
||||
<option value="" disabled>
|
||||
No lists available
|
||||
</option>
|
||||
)}
|
||||
{collections?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{collectionDisplayName(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{destinationType === 'deck' && (
|
||||
<div className="mt-3">
|
||||
<label htmlFor={destSelectId} className="sr-only">
|
||||
Choose a deck
|
||||
</label>
|
||||
<select
|
||||
id={destSelectId}
|
||||
value={sessionDestination?.id ?? ''}
|
||||
onChange={handleDeckSelect}
|
||||
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
|
||||
style={{
|
||||
...selectStyle,
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
}}
|
||||
>
|
||||
{(!decks || decks.length === 0) && (
|
||||
<option value="" disabled>
|
||||
No decks available
|
||||
</option>
|
||||
)}
|
||||
{decks?.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Game filter */}
|
||||
<fieldset>
|
||||
<legend
|
||||
id={gameGroupId}
|
||||
className="text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Game
|
||||
</legend>
|
||||
<ToggleGroup
|
||||
options={GAMES}
|
||||
value={gameFilter}
|
||||
onChange={onGameFilterChange}
|
||||
name="Game filter"
|
||||
className="w-full"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Deck mode toggle */}
|
||||
<div className="flex items-center justify-between gap-3 py-1">
|
||||
<label
|
||||
htmlFor={deckModeId}
|
||||
className="text-sm font-medium cursor-pointer select-none"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Deck Mode
|
||||
</label>
|
||||
<button
|
||||
id={deckModeId}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(deckMode)}
|
||||
onClick={handleDeckModeToggle}
|
||||
className="relative inline-flex h-7 w-12 shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: deckMode ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
'--tw-ring-offset-color': 'transparent',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
transform: deckMode ? 'translate(22px, 4px)' : 'translate(4px, 4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deckMode && (
|
||||
<fieldset className="pl-1">
|
||||
<legend
|
||||
id={deckSizeId}
|
||||
className="text-sm font-medium mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
Deck Size
|
||||
</legend>
|
||||
<ToggleGroup
|
||||
options={DECK_SIZES}
|
||||
value={deckMode.targetSize}
|
||||
onChange={handleDeckSizeChange}
|
||||
name="Deck size"
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Start scanning CTA */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onStartScanning}
|
||||
className="w-full"
|
||||
>
|
||||
Start Scanning
|
||||
</Button>
|
||||
|
||||
{/* Scan history */}
|
||||
<ScanHistoryList history={scanHistory} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/scanner/ScannerToast.js
Normal file
58
components/scanner/ScannerToast.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const ICON_MAP = {
|
||||
success: { glyph: '✓', color: 'var(--color-success, #10B981)' },
|
||||
error: { glyph: '✗', color: 'var(--color-error, #EF4444)' },
|
||||
info: { glyph: 'ℹ', color: 'var(--color-info, #3B82F6)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating toast overlay for the camera viewport.
|
||||
*
|
||||
* Always rendered in the DOM so CSS transitions work on enter and exit.
|
||||
* The parent controls visibility via the `visible` prop and handles the
|
||||
* auto-dismiss timer.
|
||||
*/
|
||||
export default function ScannerToast({ message, visible, type = 'success' }) {
|
||||
const { glyph, color } = ICON_MAP[type] || ICON_MAP.success;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute left-1/2 z-20 pointer-events-none"
|
||||
style={{
|
||||
bottom: '33%',
|
||||
transform: `translateX(-50%) translateY(${visible ? '0' : '12px'})`,
|
||||
opacity: visible ? 1 : 0,
|
||||
transition: 'opacity 200ms ease, transform 200ms ease',
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
transitionProperty: 'opacity, transform, visibility',
|
||||
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-full px-4 py-3 shadow-lg pointer-events-auto"
|
||||
style={{
|
||||
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.9)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
border: '1px solid var(--border)',
|
||||
maxWidth: 280,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-bold flex-shrink-0"
|
||||
style={{ color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,6 +96,21 @@ export async function createScannerCollection(name) {
|
|||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchBatchOwnership(cardIds) {
|
||||
const response = await fetch('/api/cards/batch-ownership', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ cardIds }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch ownership data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.ownership;
|
||||
}
|
||||
|
||||
export async function routeScannedCardToDestination(cardData, destination) {
|
||||
if (!destination || !cardData.databaseId) return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,15 +78,16 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
|||
const startDetection = () => {
|
||||
if (detectionIntervalRef.current || !isStreamingRef.current) return;
|
||||
|
||||
console.log('🎯 Starting continuous card detection...');
|
||||
setIsDetecting(true);
|
||||
|
||||
detectionIntervalRef.current = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
const shapes = detectCardShapes();
|
||||
updateTrackedCards(shapes);
|
||||
}, SHAPE_DETECTION_INTERVAL_MS);
|
||||
|
||||
trackingIntervalRef.current = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (verificationPausedRef?.current) return;
|
||||
|
||||
const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current);
|
||||
|
|
@ -216,5 +217,6 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
|||
videoMetrics,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
streamRef,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
75
lib/use-scanner-flash.js
Normal file
75
lib/use-scanner-flash.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Camera torch / flash control.
|
||||
*
|
||||
* @param {React.MutableRefObject<MediaStream|null>} streamRef
|
||||
* A ref holding the active MediaStream from the camera hook.
|
||||
*/
|
||||
export function useScannerFlash(streamRef) {
|
||||
const [flashOn, setFlashOn] = useState(false);
|
||||
const [flashSupported, setFlashSupported] = useState(false);
|
||||
|
||||
const streamIdentityRef = useRef(null);
|
||||
|
||||
const getVideoTrack = useCallback(() => {
|
||||
const stream = streamRef?.current;
|
||||
if (!stream) return null;
|
||||
const tracks = stream.getVideoTracks();
|
||||
return tracks.length > 0 ? tracks[0] : null;
|
||||
}, [streamRef]);
|
||||
|
||||
const probeFlashSupport = useCallback(() => {
|
||||
const track = getVideoTrack();
|
||||
if (!track) {
|
||||
setFlashSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilities = track.getCapabilities?.();
|
||||
setFlashSupported(Boolean(capabilities?.torch));
|
||||
|
||||
const handleEnded = () => {
|
||||
setFlashOn(false);
|
||||
setFlashSupported(false);
|
||||
};
|
||||
track.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
track.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [getVideoTrack]);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
const currentStream = streamRef?.current ?? null;
|
||||
if (currentStream !== streamIdentityRef.current) {
|
||||
streamIdentityRef.current = currentStream;
|
||||
probeFlashSupport();
|
||||
}
|
||||
};
|
||||
|
||||
const initialTimer = setTimeout(check, 0);
|
||||
const interval = setInterval(check, 500);
|
||||
return () => {
|
||||
clearTimeout(initialTimer);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [probeFlashSupport, streamRef]);
|
||||
|
||||
const toggleFlash = useCallback(async () => {
|
||||
if (!flashSupported) return;
|
||||
const track = getVideoTrack();
|
||||
if (!track) return;
|
||||
|
||||
const next = !flashOn;
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ torch: next }] });
|
||||
setFlashOn(next);
|
||||
} catch (err) {
|
||||
console.warn('Torch toggle failed:', err);
|
||||
}
|
||||
}, [flashSupported, flashOn, getVideoTrack]);
|
||||
|
||||
return { flashSupported, flashOn, toggleFlash };
|
||||
}
|
||||
135
lib/use-scanner-offline.js
Normal file
135
lib/use-scanner-offline.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
addScannedCardToOwned,
|
||||
addScannedCardToCollection,
|
||||
addScannedCardToDeck,
|
||||
} from './scanner-route-api.js';
|
||||
|
||||
const OFFLINE_QUEUE_KEY = 'deckhearth:offline-scan-queue';
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
function loadQueue() {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY)) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistQueue(queue) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
||||
}
|
||||
|
||||
async function executeAction(action, payload) {
|
||||
switch (action) {
|
||||
case 'addToOwned':
|
||||
return addScannedCardToOwned(payload);
|
||||
case 'addToCollection':
|
||||
return addScannedCardToCollection(payload, payload.collectionId);
|
||||
case 'addToDeck':
|
||||
return addScannedCardToDeck(payload, payload.deckId);
|
||||
default:
|
||||
throw new Error(`Unknown offline action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function useScannerOffline() {
|
||||
const [isOnline, setIsOnline] = useState(
|
||||
() => typeof navigator !== 'undefined' ? navigator.onLine : true
|
||||
);
|
||||
const [queuedActions, setQueuedActions] = useState(loadQueue);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
const updateQueue = useCallback((updater) => {
|
||||
setQueuedActions((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
persistQueue(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
|
||||
try {
|
||||
const current = loadQueue();
|
||||
const pending = current.filter((item) => item.status === 'pending');
|
||||
|
||||
for (const item of pending) {
|
||||
try {
|
||||
await executeAction(item.action, item.payload);
|
||||
updateQueue((prev) => prev.filter((q) => q.id !== item.id));
|
||||
} catch {
|
||||
updateQueue((prev) =>
|
||||
prev.map((q) => {
|
||||
if (q.id !== item.id) return q;
|
||||
const retries = q.retries + 1;
|
||||
return {
|
||||
...q,
|
||||
retries,
|
||||
status: retries >= MAX_RETRIES ? 'failed' : 'pending',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
}, [updateQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const goOnline = () => {
|
||||
setIsOnline(true);
|
||||
processQueue();
|
||||
};
|
||||
const goOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', goOnline);
|
||||
window.addEventListener('offline', goOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', goOnline);
|
||||
window.removeEventListener('offline', goOffline);
|
||||
};
|
||||
}, [processQueue]);
|
||||
|
||||
// Process any items left over from a previous session on mount
|
||||
useEffect(() => {
|
||||
if (isOnline && loadQueue().some((q) => q.status === 'pending')) {
|
||||
processQueue();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
async (action, payload) => {
|
||||
const item = {
|
||||
id: Date.now() + Math.random(),
|
||||
action,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
if (isOnline) {
|
||||
try {
|
||||
await executeAction(action, payload);
|
||||
return;
|
||||
} catch {
|
||||
// Network error while supposedly online — fall through to queue
|
||||
}
|
||||
}
|
||||
|
||||
updateQueue((prev) => [...prev, item]);
|
||||
},
|
||||
[isOnline, updateQueue]
|
||||
);
|
||||
|
||||
const pendingCount = queuedActions.filter((q) => q.status === 'pending').length;
|
||||
|
||||
return { isOnline, queuedActions, enqueue, processQueue, pendingCount };
|
||||
}
|
||||
|
|
@ -1,19 +1,24 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
addScannedCardToCollection,
|
||||
addScannedCardToDeck,
|
||||
addScannedCardToOwned,
|
||||
createScannerCollection,
|
||||
fetchBatchOwnership,
|
||||
fetchScannerCollections,
|
||||
fetchScannerDecks,
|
||||
routeScannedCardToDestination,
|
||||
} from './scanner-route-api.js';
|
||||
import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js';
|
||||
|
||||
const UNDO_TTL_MS = 30_000;
|
||||
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
|
||||
const OWNERSHIP_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
|
||||
*/
|
||||
export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
|
||||
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
|
||||
const [scannedCards, setScannedCards] = useState([]);
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [decks, setDecks] = useState([]);
|
||||
|
|
@ -24,6 +29,8 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
|
|||
const [autoRouteError, setAutoRouteError] = useState(null);
|
||||
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [undoStack, setUndoStack] = useState([]);
|
||||
const [ownershipMap, setOwnershipMap] = useState({});
|
||||
|
||||
const addingInFlightRef = useRef(new Set());
|
||||
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
||||
|
|
@ -68,14 +75,66 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
|
|||
syncAddingState();
|
||||
};
|
||||
|
||||
// --- Undo stack: clear expired entries every 5s ---
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const cutoff = Date.now() - UNDO_TTL_MS;
|
||||
setUndoStack((prev) => {
|
||||
const next = prev.filter((entry) => entry.timestamp > cutoff);
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, UNDO_CLEANUP_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// --- Batch ownership: debounced fetch for new card IDs ---
|
||||
const ownershipTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const dbIds = scannedCards
|
||||
.filter((c) => c.databaseId)
|
||||
.map((c) => c.databaseId);
|
||||
const newIds = dbIds.filter((id) => !(id in ownershipMap));
|
||||
|
||||
if (newIds.length === 0) return;
|
||||
|
||||
clearTimeout(ownershipTimerRef.current);
|
||||
ownershipTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await fetchBatchOwnership(newIds);
|
||||
setOwnershipMap((prev) => ({ ...prev, ...result }));
|
||||
} catch (err) {
|
||||
console.error('Batch ownership fetch failed:', err);
|
||||
}
|
||||
}, OWNERSHIP_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(ownershipTimerRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ownershipMap in deps would cause infinite loop
|
||||
}, [scannedCards, user]);
|
||||
|
||||
// --- Deck mode completion: derived state, no effect needed ---
|
||||
const deckComplete = Boolean(deckMode && scannedCards.length >= deckMode.targetSize);
|
||||
|
||||
const markCardAsProcessed = (cardId, action) => {
|
||||
setScannedCards((prev) =>
|
||||
prev.map((card) =>
|
||||
card.id === cardId ? { ...card, processed: true, processedAction: action } : card
|
||||
)
|
||||
);
|
||||
setUndoStack((prev) => [...prev, { cardId, action, timestamp: Date.now() }]);
|
||||
};
|
||||
|
||||
const undoCardProcess = useCallback((cardId) => {
|
||||
setScannedCards((prev) =>
|
||||
prev.map((card) =>
|
||||
card.id === cardId ? { ...card, processed: false, processedAction: null } : card
|
||||
)
|
||||
);
|
||||
setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
|
||||
}, []);
|
||||
|
||||
const handleCardScanned = async (cardData) => {
|
||||
setAutoRouteError(null);
|
||||
|
||||
|
|
@ -285,5 +344,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
|
|||
removeScannedCard,
|
||||
toggleCardSelection,
|
||||
clearSelection,
|
||||
undoCardProcess,
|
||||
undoStack,
|
||||
deckComplete,
|
||||
ownershipMap,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
102
lib/use-scanner-session.js
Normal file
102
lib/use-scanner-session.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { VOCAB } from './collection-vocabulary.js';
|
||||
|
||||
const STORAGE_KEY = 'deckhearth:scanner-session';
|
||||
const HISTORY_KEY = 'deckhearth:scan-history';
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
|
||||
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
||||
|
||||
function readJson(key, fallback) {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* quota exceeded — silently degrade */
|
||||
}
|
||||
}
|
||||
|
||||
export function useScannerSession() {
|
||||
const [sessionDestination, setSessionDestination] = useState(() => {
|
||||
const saved = readJson(STORAGE_KEY, {});
|
||||
return saved.destination ?? DEFAULT_DESTINATION;
|
||||
});
|
||||
const [gameFilter, setGameFilter] = useState(() => {
|
||||
const saved = readJson(STORAGE_KEY, {});
|
||||
return saved.gameFilter ?? 'All';
|
||||
});
|
||||
const [scanDefaults, setScanDefaults] = useState(() => {
|
||||
const saved = readJson(STORAGE_KEY, {});
|
||||
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
|
||||
});
|
||||
const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
|
||||
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialRender.current) {
|
||||
isInitialRender.current = false;
|
||||
return;
|
||||
}
|
||||
writeJson(STORAGE_KEY, {
|
||||
destination: sessionDestination,
|
||||
gameFilter,
|
||||
scanDefaults,
|
||||
});
|
||||
}, [sessionDestination, gameFilter, scanDefaults]);
|
||||
|
||||
const handleGameFilterChange = useCallback((nextFilter) => {
|
||||
setGameFilter(nextFilter);
|
||||
setSessionDestination((current) => {
|
||||
if (!current || current.type === 'owned') return current;
|
||||
return DEFAULT_DESTINATION;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDestinationChange = useCallback((next) => {
|
||||
setSessionDestination(next);
|
||||
}, []);
|
||||
|
||||
const handleScanDefaultsChange = useCallback((patch) => {
|
||||
setScanDefaults((current) => ({ ...current, ...patch }));
|
||||
}, []);
|
||||
|
||||
const saveScanToHistory = useCallback(
|
||||
(cardCount) => {
|
||||
setScanHistory((prev) => {
|
||||
const entry = {
|
||||
date: new Date().toISOString(),
|
||||
cardCount,
|
||||
destination: sessionDestination,
|
||||
game: gameFilter,
|
||||
};
|
||||
const next = [entry, ...prev].slice(0, MAX_HISTORY);
|
||||
writeJson(HISTORY_KEY, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[sessionDestination, gameFilter]
|
||||
);
|
||||
|
||||
return {
|
||||
sessionDestination,
|
||||
setSessionDestination: handleDestinationChange,
|
||||
gameFilter,
|
||||
setGameFilter: handleGameFilterChange,
|
||||
scanDefaults,
|
||||
setScanDefaults: handleScanDefaultsChange,
|
||||
scanHistory,
|
||||
saveScanToHistory,
|
||||
};
|
||||
}
|
||||
101
lib/use-scanner-sound.js
Normal file
101
lib/use-scanner-sound.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'deckhearth:scanner-sound';
|
||||
|
||||
function isMobileViewport() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function readPersistedPreference() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private browsing on some browsers)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan sound feedback via the Web Audio API.
|
||||
*
|
||||
* - Persists the enabled/disabled preference to localStorage.
|
||||
* - Defaults to ON on mobile, OFF on desktop.
|
||||
* - AudioContext created lazily on first playback (browser autoplay policy).
|
||||
*/
|
||||
export function useScannerSound() {
|
||||
const [enabled, setEnabledState] = useState(() => {
|
||||
const persisted = readPersistedPreference();
|
||||
return persisted ?? isMobileViewport();
|
||||
});
|
||||
|
||||
const audioCtxRef = useRef(null);
|
||||
|
||||
const setEnabled = useCallback((value) => {
|
||||
setEnabledState((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getAudioContext = useCallback(() => {
|
||||
if (!audioCtxRef.current) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return null;
|
||||
audioCtxRef.current = new AudioCtx();
|
||||
}
|
||||
if (audioCtxRef.current.state === 'suspended') {
|
||||
audioCtxRef.current.resume().catch(() => {});
|
||||
}
|
||||
return audioCtxRef.current;
|
||||
}, []);
|
||||
|
||||
const playTone = useCallback(
|
||||
(frequency, durationMs) => {
|
||||
if (!enabled) return;
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||||
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
0.001,
|
||||
ctx.currentTime + durationMs / 1000
|
||||
);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + durationMs / 1000);
|
||||
},
|
||||
[enabled, getAudioContext]
|
||||
);
|
||||
|
||||
const playSuccess = useCallback(() => playTone(880, 50), [playTone]);
|
||||
const playError = useCallback(() => playTone(440, 80), [playTone]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { enabled, setEnabled, playSuccess, playError };
|
||||
}
|
||||
50
pages/api/cards/batch-ownership.js
Normal file
50
pages/api/cards/batch-ownership.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { cardIds } = req.body || {};
|
||||
|
||||
if (!Array.isArray(cardIds) || cardIds.length === 0) {
|
||||
return res.status(400).json({ error: 'cardIds must be a non-empty array' });
|
||||
}
|
||||
|
||||
if (cardIds.length > 100) {
|
||||
return res.status(400).json({ error: 'cardIds cannot exceed 100 items' });
|
||||
}
|
||||
|
||||
const valid = cardIds.every(
|
||||
(id) => Number.isInteger(id) && id > 0
|
||||
);
|
||||
if (!valid) {
|
||||
return res.status(400).json({ error: 'All cardIds must be positive integers' });
|
||||
}
|
||||
|
||||
const result = await sql`
|
||||
SELECT card_id, SUM(quantity)::int AS total_quantity
|
||||
FROM user_cards
|
||||
WHERE user_id = ${user.userId}
|
||||
AND card_id = ANY(${cardIds})
|
||||
GROUP BY card_id
|
||||
`;
|
||||
|
||||
const ownership = {};
|
||||
for (const row of result.rows) {
|
||||
ownership[row.card_id] = row.total_quantity;
|
||||
}
|
||||
|
||||
return res.status(200).json({ ownership });
|
||||
} catch (error) {
|
||||
console.error('[POST /api/cards/batch-ownership]', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
133
pages/scanner.js
133
pages/scanner.js
|
|
@ -1,26 +1,51 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '../components/Layout';
|
||||
import ScannerPageView from '../components/ScannerPageView';
|
||||
import ScannerSetup from '../components/scanner/ScannerSetup';
|
||||
import ScannerCamera from '../components/scanner/ScannerCamera';
|
||||
import ScannerReview from '../components/scanner/ScannerReview';
|
||||
import { useAuth } from '../lib/use-auth';
|
||||
import {
|
||||
DEFAULT_SCANNER_DESTINATION,
|
||||
loadSavedScannerSession,
|
||||
saveScannerSession,
|
||||
} from '../lib/scanner-session.js';
|
||||
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';
|
||||
|
||||
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 [phase, setPhase] = useState('setup');
|
||||
const [deckMode, setDeckMode] = useState(null);
|
||||
|
||||
const queue = useScannerQueue({ user, sessionDestination, scanDefaults });
|
||||
const {
|
||||
sessionDestination,
|
||||
setSessionDestination,
|
||||
gameFilter,
|
||||
setGameFilter,
|
||||
scanDefaults,
|
||||
setScanDefaults,
|
||||
scanHistory,
|
||||
saveScanToHistory,
|
||||
} = useScannerSession();
|
||||
|
||||
const queue = useScannerQueue({ user, sessionDestination, scanDefaults, deckMode });
|
||||
|
||||
const onVerifyCardRef = useRef(null);
|
||||
const verificationPausedRef = useRef(false);
|
||||
|
||||
const camera = useCameraScanner({
|
||||
onError: (msg) => console.error('Camera error:', msg),
|
||||
onVerifyCard: (cardTracker) => onVerifyCardRef.current?.(cardTracker),
|
||||
verificationPausedRef,
|
||||
});
|
||||
|
||||
const identification = useScannerIdentification({
|
||||
onCardScanned: queue.handleCardScanned,
|
||||
onError: (msg) => console.error('Identification error:', msg),
|
||||
videoRef: camera.videoRef,
|
||||
canvasRef: camera.canvasRef,
|
||||
onVerifyCardRef,
|
||||
verificationPausedRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
|
|
@ -28,51 +53,69 @@ export default function Scanner() {
|
|||
}
|
||||
}, [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 handleScannerError = (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
|
||||
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 null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<ScannerPageView
|
||||
gameFilter={gameFilter}
|
||||
onGameFilterChange={handleGameFilterChange}
|
||||
sessionDestination={sessionDestination}
|
||||
onDestinationChange={setSessionDestination}
|
||||
scanDefaults={scanDefaults}
|
||||
onScanDefaultsChange={(patch) => setScanDefaults((current) => ({ ...current, ...patch }))}
|
||||
queue={queue}
|
||||
showOCRSettings={showOCRSettings}
|
||||
onOpenOCRSettings={() => setShowOCRSettings(true)}
|
||||
onCloseOCRSettings={() => setShowOCRSettings(false)}
|
||||
onScannerError={handleScannerError}
|
||||
/>
|
||||
{phase === 'setup' && (
|
||||
<ScannerSetup
|
||||
sessionDestination={sessionDestination}
|
||||
onDestinationChange={setSessionDestination}
|
||||
gameFilter={gameFilter}
|
||||
onGameFilterChange={setGameFilter}
|
||||
collections={queue.collections}
|
||||
decks={queue.decks}
|
||||
deckMode={deckMode}
|
||||
onDeckModeChange={setDeckMode}
|
||||
onStartScanning={() => setPhase('scanning')}
|
||||
scanHistory={scanHistory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && (
|
||||
<ScannerCamera
|
||||
queue={queue}
|
||||
camera={camera}
|
||||
identification={identification}
|
||||
deckMode={deckMode}
|
||||
onReview={() => setPhase('review')}
|
||||
onStopSession={() => {
|
||||
camera.stopCamera();
|
||||
setPhase('review');
|
||||
}}
|
||||
sessionDestination={sessionDestination}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'review' && (
|
||||
<ScannerReview
|
||||
queue={queue}
|
||||
collections={queue.collections}
|
||||
decks={queue.decks}
|
||||
deckMode={deckMode}
|
||||
onContinueScanning={() => setPhase('scanning')}
|
||||
onNewSession={() => {
|
||||
queue.clearScannedCards();
|
||||
setPhase('setup');
|
||||
}}
|
||||
sessionDestination={sessionDestination}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue