Polish scanner checkout glass surfaces and remove dead code. (#161)
Align toast, sheet, and Review N pill with GlassSurface tokens, use solid cart rows to avoid stacked blurs, and delete pre-rebuild scanner components no longer referenced by /scanner. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f6305f96f2
commit
484bd02f9a
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
|
||||
to My Collection or a List without leaving the session.
|
||||
skip: []
|
||||
status: open
|
||||
status: shipped
|
||||
created: 2026-08-14
|
||||
depends_on:
|
||||
- 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. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
|
||||
import GlassSurface from '../ui/GlassSurface.js';
|
||||
|
||||
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
||||
|
||||
|
|
@ -61,9 +62,11 @@ export default function ReviewCardItem({
|
|||
|
||||
return (
|
||||
<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={{
|
||||
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">
|
||||
|
|
@ -158,10 +161,14 @@ export default function ReviewCardItem({
|
|||
</svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
<GlassSurface
|
||||
as="div"
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1 glass-panel-strong shadow-lg"
|
||||
style={{ border: '1px solid var(--border)' }}
|
||||
tint="high"
|
||||
rim="subtle"
|
||||
blur="mid"
|
||||
elevation="ambient"
|
||||
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -205,7 +212,7 @@ export default function ReviewCardItem({
|
|||
>
|
||||
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
|
||||
</button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</div>
|
||||
) : !card.processed ? (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import { useFocusTrap } from '../../lib/use-focus-trap';
|
||||
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||||
import { Button } from '../ui';
|
||||
import GlassSurface from '../ui/GlassSurface.js';
|
||||
import ReviewCardItem from './ReviewCardItem';
|
||||
|
||||
function confidencePercent(confidence) {
|
||||
|
|
@ -20,8 +21,9 @@ function CheckoutFooter({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="px-4 sm:px-6 py-4 glass-panel-strong"
|
||||
className="px-4 sm:px-6 py-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||||
}}
|
||||
|
|
@ -213,9 +215,12 @@ export default function ScannerCheckoutSheet({
|
|||
aria-modal="true"
|
||||
aria-labelledby="checkout-sheet-title"
|
||||
>
|
||||
<div
|
||||
<GlassSurface
|
||||
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
|
||||
|
|
@ -245,7 +250,7 @@ export default function ScannerCheckoutSheet({
|
|||
onOpenListPicker={onOpenListPicker}
|
||||
onEmpty={onClose}
|
||||
/>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<style jsx>{`
|
||||
.checkout-backdrop {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import GlassSurface from '../ui/GlassSurface.js';
|
||||
|
||||
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
||||
const handleClick = onOpenCheckout || onReview;
|
||||
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
<GlassSurface
|
||||
as="button"
|
||||
type="button"
|
||||
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"
|
||||
style={{
|
||||
background: 'var(--gradient-primary)',
|
||||
color: 'var(--text-primary-dark)',
|
||||
boxShadow: 'var(--ember-rim-pronounced)',
|
||||
minHeight: 44,
|
||||
minWidth: 44,
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
|
|
@ -20,6 +25,6 @@ export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
|||
aria-label={`Review ${count} scanned cards`}
|
||||
>
|
||||
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 }) {
|
||||
if (type === 'error') {
|
||||
return (
|
||||
|
|
@ -87,15 +89,13 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
|
|||
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,
|
||||
}}
|
||||
<GlassSurface
|
||||
tint="high"
|
||||
blur="low"
|
||||
rim="subtle"
|
||||
elevation="ambient"
|
||||
className="flex items-center gap-2 rounded-full px-4 py-3 pointer-events-auto"
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
|
|
@ -110,7 +110,7 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
|
|||
>
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function getFocusableElements(container) {
|
|||
* Trap focus inside a container while `active` and restore focus on close.
|
||||
* 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
|
||||
* to share / forward the panel ref should use the default export
|
||||
* `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';
|
||||
|
||||
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 };
|
||||
|
|
@ -40,8 +38,6 @@ export function useScannerSession() {
|
|||
const saved = readJson(STORAGE_KEY, {});
|
||||
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
|
||||
});
|
||||
const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
|
||||
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -72,23 +68,6 @@ export function useScannerSession() {
|
|||
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,
|
||||
|
|
@ -96,7 +75,5 @@ export function useScannerSession() {
|
|||
setGameFilter: handleGameFilterChange,
|
||||
scanDefaults,
|
||||
setScanDefaults: handleScanDefaultsChange,
|
||||
scanHistory,
|
||||
saveScanToHistory,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue