feat(scanner): add debug instrumentation for vision pipeline timing #155
12 changed files with 42 additions and 1100 deletions
|
|
@ -7,7 +7,7 @@ success_metric: |
|
||||||
the user can open a checkout sheet, select cards, and commit them
|
the user can open a checkout sheet, select cards, and commit them
|
||||||
to My Collection or a List without leaving the session.
|
to My Collection or a List without leaving the session.
|
||||||
skip: []
|
skip: []
|
||||||
status: open
|
status: shipped
|
||||||
created: 2026-08-14
|
created: 2026-08-14
|
||||||
depends_on:
|
depends_on:
|
||||||
- scanner-rebuild
|
- scanner-rebuild
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { useRef } from 'react';
|
|
||||||
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
|
||||||
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
|
||||||
import CameraScannerView from './CameraScannerView.js';
|
|
||||||
|
|
||||||
export default function CameraScanner({ onCardScanned, onError }) {
|
|
||||||
const verificationPausedRef = useRef(false);
|
|
||||||
const onVerifyCardRef = useRef(() => {});
|
|
||||||
|
|
||||||
const camera = useCameraScanner({
|
|
||||||
onError,
|
|
||||||
verificationPausedRef,
|
|
||||||
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
|
||||||
});
|
|
||||||
|
|
||||||
const identification = useScannerIdentification({
|
|
||||||
onCardScanned,
|
|
||||||
onError,
|
|
||||||
videoRef: camera.videoRef,
|
|
||||||
canvasRef: camera.canvasRef,
|
|
||||||
onVerifyCardRef,
|
|
||||||
verificationPausedRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CameraScannerView
|
|
||||||
videoRef={camera.videoRef}
|
|
||||||
canvasRef={camera.canvasRef}
|
|
||||||
detectionCanvasRef={camera.detectionCanvasRef}
|
|
||||||
isStreaming={camera.isStreaming}
|
|
||||||
isDetecting={camera.isDetecting}
|
|
||||||
trackedCards={camera.trackedCards}
|
|
||||||
videoMetrics={camera.videoMetrics}
|
|
||||||
startCamera={camera.startCamera}
|
|
||||||
stopCamera={camera.stopCamera}
|
|
||||||
scanNotice={identification.scanNotice}
|
|
||||||
disambiguation={identification.disambiguation}
|
|
||||||
submittingReview={identification.submittingReview}
|
|
||||||
onPick={identification.handleDisambiguationPick}
|
|
||||||
onNotInCatalog={identification.handleNotInCatalog}
|
|
||||||
onCancelDisambiguation={identification.cancelDisambiguation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
|
||||||
|
|
||||||
const CONFIRMED_COLOR = '#10B981';
|
|
||||||
const SCANNED_COLOR = '#3B82F6';
|
|
||||||
|
|
||||||
function overlayBorderColor(status) {
|
|
||||||
if (status === 'scanned') return SCANNED_COLOR;
|
|
||||||
return CONFIRMED_COLOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlayGlowColor(status) {
|
|
||||||
if (status === 'scanned') return `${SCANNED_COLOR}50`;
|
|
||||||
return `${CONFIRMED_COLOR}50`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CameraScannerView({
|
|
||||||
videoRef,
|
|
||||||
canvasRef,
|
|
||||||
detectionCanvasRef,
|
|
||||||
isStreaming,
|
|
||||||
isDetecting,
|
|
||||||
trackedCards,
|
|
||||||
videoMetrics,
|
|
||||||
startCamera,
|
|
||||||
stopCamera,
|
|
||||||
scanNotice,
|
|
||||||
disambiguation,
|
|
||||||
submittingReview,
|
|
||||||
onPick,
|
|
||||||
onNotInCatalog,
|
|
||||||
onCancelDisambiguation,
|
|
||||||
}) {
|
|
||||||
const foundCards = trackedCards.filter(
|
|
||||||
(card) => card.status === 'confirmed' || card.status === 'scanned'
|
|
||||||
);
|
|
||||||
const foundCardCount = foundCards.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col">
|
|
||||||
{scanNotice && (
|
|
||||||
<div
|
|
||||||
className="mx-4 mt-3 px-4 py-3 rounded-lg text-sm border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
|
||||||
borderColor: 'var(--accent-ember)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{scanNotice}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="flex-1 relative rounded-2xl overflow-hidden mb-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
border: '2px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className={`absolute inset-0 w-full h-full object-cover ${isStreaming ? 'block' : 'hidden'}`}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
aria-label="Card scanner camera feed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isStreaming && videoMetrics.width > 0 && videoMetrics.height > 0 && foundCards.map((card) => (
|
|
||||||
<div
|
|
||||||
key={card.id}
|
|
||||||
className="absolute border-2 rounded-lg transition-all duration-200"
|
|
||||||
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}%`,
|
|
||||||
borderColor: overlayBorderColor(card.status),
|
|
||||||
borderWidth: '3px',
|
|
||||||
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute -top-8 left-0 px-3 py-1 rounded-full text-xs font-bold text-white shadow-lg"
|
|
||||||
style={{ backgroundColor: overlayBorderColor(card.status) }}
|
|
||||||
>
|
|
||||||
{card.status === 'scanned' ? '📸 Scanned' : '✅ Card Found'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{isStreaming && (
|
|
||||||
<div className="absolute top-4 left-4 right-4 flex justify-between items-center">
|
|
||||||
{isDetecting && (
|
|
||||||
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
|
||||||
🎯 Scanning {foundCardCount > 0 && `(${foundCardCount} found)`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1"></div>
|
|
||||||
|
|
||||||
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
|
||||||
LIVE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStreaming && (
|
|
||||||
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={stopCamera}
|
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-105 transition-all duration-200 backdrop-blur-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
|
||||||
border: '3px solid rgba(255, 255, 255, 0.3)',
|
|
||||||
}}
|
|
||||||
aria-label="Stop camera"
|
|
||||||
>
|
|
||||||
<div className="w-6 h-6 bg-white rounded-sm" aria-hidden="true"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isStreaming && (
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<div className="absolute inset-0 opacity-5">
|
|
||||||
<div
|
|
||||||
className="w-full h-full"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `radial-gradient(circle at 25% 25%, var(--text-primary) 2px, transparent 2px),
|
|
||||||
radial-gradient(circle at 75% 75%, var(--text-primary) 2px, transparent 2px)`,
|
|
||||||
backgroundSize: '50px 50px',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center z-10 mb-8">
|
|
||||||
<div className="font-semibold text-2xl mb-3" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Camera Ready
|
|
||||||
</div>
|
|
||||||
<div className="text-lg opacity-75 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Position trading cards in view for smart detection
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={startCamera}
|
|
||||||
className="w-20 h-20 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-110 transition-all duration-300 relative overflow-hidden group"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
border: '4px solid rgba(255, 255, 255, 0.2)',
|
|
||||||
}}
|
|
||||||
aria-label="Start camera"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
||||||
style={{
|
|
||||||
background: 'radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%)',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div className="relative z-10" aria-hidden="true">
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-white opacity-60 animate-ping"></div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mt-8 text-center max-w-md">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-gold)' }}></div>
|
|
||||||
<span>AI Detection</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-ember)' }}></div>
|
|
||||||
<span>Real-time Tracking</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-flame)' }}></div>
|
|
||||||
<span>Smart Recognition</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
|
||||||
<span>Auto Scanning</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
<canvas ref={detectionCanvasRef} className="hidden" />
|
|
||||||
|
|
||||||
{isStreaming && (
|
|
||||||
<div
|
|
||||||
className="glass-panel rounded-xl p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: 'var(--accent-ember)' }}
|
|
||||||
>
|
|
||||||
<span className="text-white text-xs">🎯</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Smart Detection Active
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-green-500">●</span>
|
|
||||||
<span>Shape recognition</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-blue-500">●</span>
|
|
||||||
<span>Server identification</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-yellow-500">●</span>
|
|
||||||
<span>Position tracking</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-purple-500">●</span>
|
|
||||||
<span>Database lookup</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScanDisambiguationDialog
|
|
||||||
disambiguation={disambiguation}
|
|
||||||
submittingReview={submittingReview}
|
|
||||||
onPick={onPick}
|
|
||||||
onNotInCatalog={onNotInCatalog}
|
|
||||||
onCancel={onCancelDisambiguation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
import CameraScanner from './CameraScanner';
|
|
||||||
import ScannerDestinationPicker from './ScannerDestinationPicker';
|
|
||||||
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
|
|
||||||
import OCRSettings from './OCRSettings';
|
|
||||||
import { Modal, Input, Button } from './ui';
|
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
|
||||||
|
|
||||||
export default function ScannerPageView({
|
|
||||||
gameFilter,
|
|
||||||
onGameFilterChange,
|
|
||||||
sessionDestination,
|
|
||||||
onDestinationChange,
|
|
||||||
scanDefaults,
|
|
||||||
onScanDefaultsChange,
|
|
||||||
queue,
|
|
||||||
showOCRSettings,
|
|
||||||
onOpenOCRSettings,
|
|
||||||
onCloseOCRSettings,
|
|
||||||
onScannerError,
|
|
||||||
}) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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={onGameFilterChange}
|
|
||||||
destination={sessionDestination}
|
|
||||||
onDestinationChange={onDestinationChange}
|
|
||||||
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="glass-panel mx-6 mb-4 rounded-xl p-4 flex flex-wrap items-end gap-4"
|
|
||||||
>
|
|
||||||
<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) => onScanDefaultsChange({ 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) => onScanDefaultsChange({ 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="glass-panel flex-1 rounded-xl p-6 flex flex-col"
|
|
||||||
>
|
|
||||||
<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={onOpenOCRSettings}
|
|
||||||
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={onScannerError} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex flex-col">
|
|
||||||
<div
|
|
||||||
className="glass-panel flex-1 rounded-xl p-6 flex flex-col"
|
|
||||||
>
|
|
||||||
<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="glass-panel-strong rounded-2xl px-6 py-4 flex items-center gap-4 max-w-4xl"
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Bulk actions for selected scanned cards"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={queue.showCreateCollection}
|
|
||||||
onClose={() => queue.setShowCreateCollection(false)}
|
|
||||||
title="Create New List"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="create-collection-name"
|
|
||||||
label="List name"
|
|
||||||
placeholder="List name..."
|
|
||||||
value={queue.newCollectionName}
|
|
||||||
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
queue.createCollection();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={queue.createCollection}
|
|
||||||
disabled={!queue.newCollectionName.trim()}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => queue.setShowCreateCollection(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
|
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
|
|
||||||
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
||||||
|
|
||||||
|
|
@ -61,9 +62,11 @@ export default function ReviewCardItem({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
|
className={`rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : '3px solid transparent',
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|
@ -158,10 +161,14 @@ export default function ReviewCardItem({
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<GlassSurface
|
||||||
|
as="div"
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1 glass-panel-strong shadow-lg"
|
tint="high"
|
||||||
style={{ border: '1px solid var(--border)' }}
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
elevation="ambient"
|
||||||
|
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -205,7 +212,7 @@ export default function ReviewCardItem({
|
||||||
>
|
>
|
||||||
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
|
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</GlassSurface>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : !card.processed ? (
|
) : !card.processed ? (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useFocusTrap } from '../../lib/use-focus-trap';
|
import { useFocusTrap } from '../../lib/use-focus-trap';
|
||||||
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
import { Button } from '../ui';
|
import { Button } from '../ui';
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
import ReviewCardItem from './ReviewCardItem';
|
import ReviewCardItem from './ReviewCardItem';
|
||||||
|
|
||||||
function confidencePercent(confidence) {
|
function confidencePercent(confidence) {
|
||||||
|
|
@ -20,8 +21,9 @@ function CheckoutFooter({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-4 sm:px-6 py-4 glass-panel-strong"
|
className="px-4 sm:px-6 py-4"
|
||||||
style={{
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
borderTop: '1px solid var(--border)',
|
borderTop: '1px solid var(--border)',
|
||||||
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||||||
}}
|
}}
|
||||||
|
|
@ -213,9 +215,12 @@ export default function ScannerCheckoutSheet({
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="checkout-sheet-title"
|
aria-labelledby="checkout-sheet-title"
|
||||||
>
|
>
|
||||||
<div
|
<GlassSurface
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
|
tint="low"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
className="fixed bottom-0 left-0 right-0 rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center justify-center pt-3 pb-2 px-4">
|
<div className="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -245,7 +250,7 @@ export default function ScannerCheckoutSheet({
|
||||||
onOpenListPicker={onOpenListPicker}
|
onOpenListPicker={onOpenListPicker}
|
||||||
onEmpty={onClose}
|
onEmpty={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</GlassSurface>
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.checkout-backdrop {
|
.checkout-backdrop {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
|
|
||||||
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
||||||
const handleClick = onOpenCheckout || onReview;
|
const handleClick = onOpenCheckout || onReview;
|
||||||
|
|
||||||
if (count <= 0) return null;
|
if (count <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<GlassSurface
|
||||||
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
tint="mid"
|
||||||
|
rim="ember-pronounced"
|
||||||
|
blur="mid"
|
||||||
className="rounded-full px-4 py-2 text-sm font-semibold transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="rounded-full px-4 py-2 text-sm font-semibold transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--gradient-primary)',
|
background: 'var(--gradient-primary)',
|
||||||
color: 'var(--text-primary-dark)',
|
color: 'var(--text-primary-dark)',
|
||||||
boxShadow: 'var(--ember-rim-pronounced)',
|
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
minWidth: 44,
|
minWidth: 44,
|
||||||
'--tw-ring-color': 'var(--accent-ember)',
|
'--tw-ring-color': 'var(--accent-ember)',
|
||||||
|
|
@ -20,6 +25,6 @@ export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
||||||
aria-label={`Review ${count} scanned cards`}
|
aria-label={`Review ${count} scanned cards`}
|
||||||
>
|
>
|
||||||
Review {count}
|
Review {count}
|
||||||
</button>
|
</GlassSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,379 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
|
|
||||||
function ToastIcon({ type }) {
|
function ToastIcon({ type }) {
|
||||||
if (type === 'error') {
|
if (type === 'error') {
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,15 +89,13 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
|
||||||
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
|
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<GlassSurface
|
||||||
className="flex items-center gap-2 rounded-full px-4 py-3 shadow-lg pointer-events-auto"
|
tint="high"
|
||||||
style={{
|
blur="low"
|
||||||
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.9)',
|
rim="subtle"
|
||||||
backdropFilter: 'blur(12px)',
|
elevation="ambient"
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
className="flex items-center gap-2 rounded-full px-4 py-3 pointer-events-auto"
|
||||||
border: '1px solid var(--border)',
|
style={{ maxWidth: 280 }}
|
||||||
maxWidth: 280,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
|
|
@ -110,7 +110,7 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
|
||||||
>
|
>
|
||||||
{message}
|
{message}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</GlassSurface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ function getFocusableElements(container) {
|
||||||
* Trap focus inside a container while `active` and restore focus on close.
|
* Trap focus inside a container while `active` and restore focus on close.
|
||||||
* Returns a ref to attach to the dialog panel (not the backdrop).
|
* Returns a ref to attach to the dialog panel (not the backdrop).
|
||||||
*
|
*
|
||||||
* Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the
|
* Used by call sites where the
|
||||||
* caller wants the hook to own the container ref. New code that needs
|
* caller wants the hook to own the container ref. New code that needs
|
||||||
* to share / forward the panel ref should use the default export
|
* to share / forward the panel ref should use the default export
|
||||||
* `useFocusTrapContainer` instead — see Modal.js for the pattern.
|
* `useFocusTrapContainer` instead — see Modal.js for the pattern.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { VOCAB } from './collection-vocabulary.js';
|
import { VOCAB } from './collection-vocabulary.js';
|
||||||
|
|
||||||
const STORAGE_KEY = 'deckhearth:scanner-session';
|
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_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
|
||||||
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
||||||
|
|
@ -40,8 +38,6 @@ export function useScannerSession() {
|
||||||
const saved = readJson(STORAGE_KEY, {});
|
const saved = readJson(STORAGE_KEY, {});
|
||||||
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
|
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
|
||||||
});
|
});
|
||||||
const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
|
|
||||||
|
|
||||||
const isInitialRender = useRef(true);
|
const isInitialRender = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -72,23 +68,6 @@ export function useScannerSession() {
|
||||||
setScanDefaults((current) => ({ ...current, ...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 {
|
return {
|
||||||
sessionDestination,
|
sessionDestination,
|
||||||
setSessionDestination: handleDestinationChange,
|
setSessionDestination: handleDestinationChange,
|
||||||
|
|
@ -96,7 +75,5 @@ export function useScannerSession() {
|
||||||
setGameFilter: handleGameFilterChange,
|
setGameFilter: handleGameFilterChange,
|
||||||
scanDefaults,
|
scanDefaults,
|
||||||
setScanDefaults: handleScanDefaultsChange,
|
setScanDefaults: handleScanDefaultsChange,
|
||||||
scanHistory,
|
|
||||||
saveScanToHistory,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue