deckhearth/components/scanner/ScannerResultPanel.js

324 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { VOCAB } from '../../lib/collection-vocabulary.js';
import Button from '../ui/Button.js';
import GlassSurface from '../ui/GlassSurface.js';
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
function confidencePercent(confidence) {
if (confidence == null || Number.isNaN(confidence)) return null;
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
}
function confidenceRingColor(pct) {
if (pct == null) return 'var(--border)';
if (pct < 70) return 'var(--accent-ember)';
if (pct < 85) return 'var(--color-warning)';
return 'var(--color-success)';
}
function confidenceCaption(pct) {
if (pct == null) return null;
if (pct >= 90) return 'Excellent match.';
if (pct >= 70) return 'Good match.';
return 'Low confidence — verify before adding.';
}
function formatMetadataLine(card) {
return [card.set, card.rarity, card.cardNumber].filter(Boolean).join(' · ');
}
function EmptyStateIllustration() {
return (
<svg
className="w-16 h-16 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
style={{ color: 'var(--text-secondary)', opacity: 0.6 }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
export default function ScannerResultPanel({
focusedCard = null,
queue,
isIdentifying = false,
commitError = null,
onAddToOwned,
onAddToList,
onRescan,
}) {
if (!focusedCard) {
return (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
elevation="ambient"
className="rounded-2xl p-6 h-full flex flex-col"
>
<h2
className="text-sm font-medium uppercase tracking-wide mb-6"
style={{ color: 'var(--text-secondary)' }}
>
Scan Result
</h2>
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
{isIdentifying ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Identifying
</p>
) : (
<>
<EmptyStateIllustration />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Point your camera at a card or upload an image to see a match.
</p>
</>
)}
</div>
</GlassSurface>
);
}
const card = focusedCard;
const confidencePct = confidencePercent(card.confidence);
const isAdding = queue?.addingCardIds?.has(card.id) ?? false;
const metadataLine = formatMetadataLine(card);
const handleUpdateMetadata = (patch) => {
queue?.updateCardMetadata?.(card.id, patch);
};
return (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
elevation="ambient"
className="rounded-2xl p-6 h-full flex flex-col"
>
<style>{`
.scanner-result-confidence-bar-fill {
transition: width 300ms ease;
}
@media (prefers-reduced-motion: reduce) {
.scanner-result-confidence-bar-fill {
transition: none;
}
}
`}</style>
<div className="flex items-center justify-between gap-3 mb-6">
<h2
className="text-sm font-medium uppercase tracking-wide"
style={{ color: 'var(--text-secondary)' }}
>
Scan Result
</h2>
{confidencePct != null && (
<span
className="text-xs font-medium px-2.5 py-1 rounded-full"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: `1px solid ${confidenceRingColor(confidencePct)}`,
}}
>
{confidencePct}% Match
</span>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div
className="w-full max-w-[200px] mx-auto mb-4 rounded-xl overflow-hidden"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
aspectRatio: '5 / 7',
}}
>
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
>
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<h3
className="text-lg font-semibold text-center mb-1"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h3>
{metadataLine && (
<p className="text-sm text-center mb-5" style={{ color: 'var(--text-secondary)' }}>
{metadataLine}
</p>
)}
<div className="space-y-4 mb-6">
<div>
<label
htmlFor={`scanner-result-condition-${card.id}`}
className="text-xs font-medium block mb-1.5"
style={{ color: 'var(--text-secondary)' }}
>
Condition
</label>
<select
id={`scanner-result-condition-${card.id}`}
value={card.condition || 'NM'}
onChange={(e) => handleUpdateMetadata({ condition: e.target.value })}
className="w-full px-3 py-2 rounded-xl text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="flex items-center justify-between gap-3">
<span
id={`scanner-result-foil-label-${card.id}`}
className="text-xs font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Foil
</span>
<button
type="button"
role="switch"
aria-checked={Boolean(card.isFoil)}
aria-labelledby={`scanner-result-foil-label-${card.id}`}
onClick={() => handleUpdateMetadata({ isFoil: !card.isFoil })}
className="relative inline-flex h-7 w-12 flex-shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: card.isFoil ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
border: '1px solid var(--border)',
minWidth: 44,
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span
className="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200"
style={{
transform: card.isFoil ? 'translateX(1.35rem)' : 'translateX(0.15rem)',
marginTop: '0.35rem',
}}
aria-hidden="true"
/>
</button>
</div>
{confidencePct != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Confidence
</span>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{confidencePct}%
</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
role="presentation"
>
<div
className="scanner-result-confidence-bar-fill h-full rounded-full"
style={{
width: `${confidencePct}%`,
backgroundColor: confidenceRingColor(confidencePct),
}}
/>
</div>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)' }}>
{confidenceCaption(confidencePct)}
</p>
</div>
)}
</div>
</div>
<div className="mt-auto pt-4 space-y-3">
{commitError && (
<div
className="px-3 py-2 rounded-lg text-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
color: 'var(--color-error)',
}}
role="alert"
>
{commitError}
</div>
)}
<Button
variant="primary"
className="w-full"
loading={isAdding}
disabled={isAdding}
onClick={() => onAddToOwned?.(card)}
>
{VOCAB.ADD_TO_MY_COLLECTION}
</Button>
<div className="flex gap-2">
<Button variant="ghost" className="flex-1" onClick={() => onRescan?.(card)}>
Rescan
</Button>
<Button variant="ghost" className="flex-1" onClick={() => onAddToList?.(card)}>
{VOCAB.ADD_TO_LIST}
</Button>
</div>
</div>
</GlassSurface>
);
}