2026-06-02 17:04:02 -04:00
|
|
|
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
|
feat(design-system): sweep authenticated body-content panels to glass (#97)
PR #95/#96 shipped the Liquid Glass foundation (tokens, primitives, gates)
plus Layout shell, modals, landing, auth pages, and form CTAs — but body-
content panels on authenticated pages (admin Card Editor, admin Card
Import, admin Submissions, dashboard, my-cards, settings, scanner panels,
card detail price cards, popovers) were still rendering as flat
var(--bg-secondary) cards. Result: the admin Tools screen and several
core pages looked unchanged after the redesign.
This sweep adds a `.glass-panel` / `.glass-panel-strong` utility
(<GlassSurface tint=mid/high rim=subtle elevation=ambient/pronounced
blur=mid/high> in class form) and applies it across 18 surfaces:
* Admin Card Editor view, search panel, form (5 sections), preview
* Admin Card Import navigation + 3 body cards + sync panel
* Admin Card Submissions list items
* Dashboard stat cards + empty-state + grid items (5 surfaces)
* My-cards empty-state CTA card
* Settings panels (3)
* Scanner page settings + grid + queue + bulk toolbar + dialog
* Scanner destination picker + camera status banner + disambiguation
* Card detail price cards (Current / TCGPlayer / CardKingdom)
* Permission indicator tooltips
* Collections page header card
* Card detail view price cards
Also migrates the lingering admin Card Editor "Card Editor / Card Import"
nav buttons and the "Save Changes" / "Import Cards" / "Run catalog sync"
CTAs to the <Button> primitive (consistent loading + disabled states).
Page header bands (full-bleed strips with border-bottom on dashboard,
my-cards, cards, collections, community/collections, collection/[id])
are intentionally left solid — they're not card-shaped surfaces and
stacking glass-on-glass directly below the already-glass topbar would
muddy the hierarchy.
Tests: lint clean, vitest 104/104, build green. The visual diff
baseline will need refresh because the homepage spec is unaffected
(it targets the unauthenticated landing page) but the dashboard/
admin/scanner surfaces will diff if/when we add baselines for them.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 22:28:52 -04:00
|
|
|
className="glass-panel rounded-xl p-3"
|
2026-06-02 17:04:02 -04:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|