refactor(scanner): extract CameraScannerView component #73
3 changed files with 277 additions and 264 deletions
|
|
@ -318,7 +318,7 @@ These MUST land before any anonymous traffic touches the production URL.
|
|||
| `pages/collections.js` | 989 | Similar structure to collection/[identifier]. Possibly share extracted pieces. |
|
||||
| `pages/card/[id].js` | 913 | `CardDetail` — split into header, owned-badge, add-to-collection-flow. |
|
||||
| `pages/deck-builder.js` | 823 | `DeckBuilder` — extract card-search, deck-list, mana-curve panels. |
|
||||
| `components/CameraScanner.js` | 817 | Camera + AI-OCR + detection-loop — extract the detection loop into a hook. |
|
||||
| `components/CameraScanner.js` | ~45 | **RESOLVED 2026-06-02** — god-component-split slice shipped PRs #67–#72 + view extract. Pre-split ~1,050 lines; now composes `useCameraScanner` + `useScannerIdentification` + `CameraScannerView`. Logic lives in `lib/scanner-card-detection.js`, `lib/scanner-card-identify.js`, `lib/scan-capture-upload.js`, `components/ScanDisambiguationDialog.js`. |
|
||||
| `pages/admin/card-editor.js` | 778 | Form heavy. Use a `useFormState` pattern + separate the search-results subview. |
|
||||
| `pages/scanner.js` | 776 | Mirror of CameraScanner concerns plus queue management. |
|
||||
| `pages/settings.js` | 669 | One screen per settings section is the usual fix. |
|
||||
|
|
@ -360,7 +360,7 @@ The `getIcon` registry in `Layout.js` and `MobileNavigation.js` redefines the sa
|
|||
- **Error states** — error messages bubble to `console.error` and toast nothing. Add a global toast system (e.g. `sonner`) and wire every catch block.
|
||||
- **Empty states** — `/my-cards` and `/collections` when empty drop to "no cards yet". Replace with first-time CTA: "Scan your first card" or "Browse popular sets".
|
||||
- **Mobile drawer** — `MobileNavigation` is solid (recent commit `442e906`). One thing: the bottom-bar's active state contrast looks low in light mode; verify against AA.
|
||||
- **Camera scanner UX** — 817 lines of detection loop. Add a one-line "scanning…" status under the viewfinder and a single "captured N cards" badge. The current toolbar is busy.
|
||||
- **Camera scanner UX** — detection/identify logic split complete (PRs #67–#72); view markup in `CameraScannerView.js`. Remaining polish: theme-token cleanup for overlay hex colors, busier toolbar simplification.
|
||||
|
||||
### Role-design-system-auditor findings
|
||||
|
||||
|
|
@ -487,6 +487,7 @@ Six convoys authored from the scanner audit portfolio plan. Dependency order:
|
|||
- **`server-side-scan-pipeline`** — **RESOLVED 2026-05-27** — PR #35 (`e81dd49`). Server-owned identify, `card_submissions`, disambiguation. Convoy: `.convoys/server-side-scan-pipeline.md`.
|
||||
- **`add-real-ocr-layer`** — **RESOLVED 2026-05-27** — PR #38 (`d798e28`) + polish PR #39. Layer-1 Tesseract + `pg_trgm`. Convoy: `.convoys/add-real-ocr-layer.md`.
|
||||
- **`redesign-scanner-flow`** — **RESOLVED 2026-05-27** — PRs #42 (Brief 1), #43 (Brief 2), #44 (Brief 3). Post-PR audit `audit-redesign-scanner-flow-44` posted to PR #44; outcome comment-only. See `.convoys/redesign-scanner-flow/audit-redesign-scanner-flow-44.md`. Follow-ups: `scanner-redesign-a11y-fixes` **RESOLVED** PR #45; `scanner-user-cards-quantity-guard` **RESOLVED** PR #46; `test-scanner-redesign-surfaces` **RESOLVED** PR #47.
|
||||
- **`god-component-split` / `CameraScanner.js` slice** — **RESOLVED 2026-06-02** — PRs #67–#72 (Briefs 1–5) + view extract (`CameraScannerView.js`). Pre-split ~1,050 lines → ~45-line composer + presentational view. Remaining `god-component-split` targets: `pages/cards.js`, `pages/scanner.js`, etc. (see § P2 #13 table).
|
||||
- **`rename-collections-vocabulary`** — **RESOLVED 2026-05-29** — PR #54 + follow-up PR #55. Convoy: `.convoys/rename-collections-vocabulary.md`.
|
||||
- **`scanner-correctness-polish`** — **RESOLVED 2026-05-27** — PR #41 (`55af7e3`). Convoy: `.convoys/scanner-correctness-polish.md`.
|
||||
- **`schema-cleanup-from-scanner-audit`** (priority: P2 schema; **deferred** —
|
||||
|
|
|
|||
|
|
@ -1,284 +1,44 @@
|
|||
import { useRef } from 'react';
|
||||
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
||||
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
||||
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
||||
import CameraScannerView from './CameraScannerView.js';
|
||||
|
||||
export default function CameraScanner({ onCardScanned, onError }) {
|
||||
const verificationPausedRef = useRef(false);
|
||||
const onVerifyCardRef = useRef(() => {});
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
canvasRef,
|
||||
detectionCanvasRef,
|
||||
isStreaming,
|
||||
isDetecting,
|
||||
trackedCards,
|
||||
videoMetrics,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
} = useCameraScanner({
|
||||
const camera = useCameraScanner({
|
||||
onError,
|
||||
verificationPausedRef,
|
||||
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
||||
});
|
||||
|
||||
const {
|
||||
disambiguation,
|
||||
scanNotice,
|
||||
submittingReview,
|
||||
handleDisambiguationPick,
|
||||
handleNotInCatalog,
|
||||
cancelDisambiguation,
|
||||
} = useScannerIdentification({
|
||||
const identification = useScannerIdentification({
|
||||
onCardScanned,
|
||||
onError,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
videoRef: camera.videoRef,
|
||||
canvasRef: camera.canvasRef,
|
||||
onVerifyCardRef,
|
||||
verificationPausedRef,
|
||||
});
|
||||
|
||||
const foundCardCount = trackedCards.filter(
|
||||
(card) => card.status === 'confirmed' || card.status === 'scanned'
|
||||
).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>
|
||||
)}
|
||||
{/* Camera Feed Container */}
|
||||
<div
|
||||
className="flex-1 relative rounded-2xl overflow-hidden mb-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '2px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
{/* Video Element - Always rendered but visibility controlled */}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* Card Detection Overlays - Only when streaming */}
|
||||
{isStreaming && videoMetrics.width > 0 && videoMetrics.height > 0 && trackedCards
|
||||
.filter(card => card.status === 'confirmed' || card.status === 'scanned')
|
||||
.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:
|
||||
card.status === 'confirmed' ? '#10B981' : // Green for confirmed
|
||||
card.status === 'scanned' ? '#3B82F6' : // Blue for scanned
|
||||
'#10B981', // Default to green
|
||||
borderWidth: '3px',
|
||||
boxShadow: `0 0 15px ${
|
||||
card.status === 'confirmed' ? '#10B98150' :
|
||||
card.status === 'scanned' ? '#3B82F650' :
|
||||
'#10B98150'
|
||||
}`
|
||||
}}
|
||||
>
|
||||
{/* Status Label */}
|
||||
<div
|
||||
className="absolute -top-8 left-0 px-3 py-1 rounded-full text-xs font-bold text-white shadow-lg"
|
||||
style={{
|
||||
backgroundColor:
|
||||
card.status === 'confirmed' ? '#10B981' :
|
||||
card.status === 'scanned' ? '#3B82F6' :
|
||||
'#10B981'
|
||||
}}
|
||||
>
|
||||
{card.status === 'confirmed' ? '✅ Card Found' :
|
||||
card.status === 'scanned' ? '📸 Scanned' :
|
||||
'✅ Card Found'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Top Status Bar - Only when streaming */}
|
||||
{isStreaming && (
|
||||
<div className="absolute top-4 left-4 right-4 flex justify-between items-center">
|
||||
{/* Detection Status */}
|
||||
{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>
|
||||
|
||||
{/* Recording Indicator */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Bottom Controls Overlay - Only when streaming */}
|
||||
{isStreaming && (
|
||||
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Stop Button */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Camera Placeholder with Centered Start Button - Only when not streaming */}
|
||||
{!isStreaming && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{/* Background Pattern */}
|
||||
<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>
|
||||
|
||||
{/* Camera Icon and Content */}
|
||||
<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>
|
||||
|
||||
{/* Centered Start Camera Button */}
|
||||
<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"
|
||||
>
|
||||
{/* Button Glow Effect */}
|
||||
<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>
|
||||
|
||||
{/* Play Icon */}
|
||||
<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>
|
||||
|
||||
{/* Pulse Ring */}
|
||||
<div className="absolute inset-0 rounded-full border-2 border-white opacity-60 animate-ping"></div>
|
||||
</button>
|
||||
|
||||
{/* Feature Hints */}
|
||||
<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>
|
||||
|
||||
{/* Hidden canvases for image processing */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<canvas ref={detectionCanvasRef} className="hidden" />
|
||||
|
||||
{/* Detection Info Panel - Only show when streaming */}
|
||||
{isStreaming && (
|
||||
<div className="rounded-xl p-3 border" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
||||
<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={handleDisambiguationPick}
|
||||
onNotInCatalog={handleNotInCatalog}
|
||||
onCancel={cancelDisambiguation}
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
252
components/CameraScannerView.js
Normal file
252
components/CameraScannerView.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
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="rounded-xl p-3 border"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue