deckhearth/components/scanner/ScannerDisambiguation.js

217 lines
8.2 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 { useCallback, useEffect, useRef } from 'react';
import { useFocusTrap } from '../../lib/use-focus-trap';
export default function ScannerDisambiguation({
disambiguation,
submittingReview,
onPick,
onNotInCatalog,
onCancel,
}) {
const sheetRef = useFocusTrap(Boolean(disambiguation));
const backdropRef = useRef(null);
const scrollContainerRef = useRef(null);
useEffect(() => {
if (!disambiguation) return undefined;
const backdrop = backdropRef.current;
const sheet = sheetRef.current;
if (!backdrop || !sheet) return undefined;
requestAnimationFrame(() => {
backdrop.dataset.visible = 'true';
sheet.dataset.visible = 'true';
});
const handler = (e) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [disambiguation, onCancel, sheetRef]);
const handleBackdropClick = useCallback(
(e) => {
if (e.target === e.currentTarget) onCancel();
},
[onCancel]
);
if (!disambiguation) return null;
return (
<div
ref={backdropRef}
className="fixed inset-0 z-50 disambiguation-backdrop"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="disambiguation-sheet-title"
>
{/* Bottom sheet */}
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden disambiguation-sheet"
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-2">
<div
className="w-10 h-1 rounded-full"
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
aria-hidden="true"
/>
</div>
<div className="px-4 sm:px-6 pb-2">
<h3
id="disambiguation-sheet-title"
className="text-lg font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Which card is this?
</h3>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
</p>
{disambiguation.visionHint && (
<span
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mt-2"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--accent-ember)',
}}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
</svg>
Vision detected: {disambiguation.visionHint}
</span>
)}
</div>
{/* Horizontal candidate scroll */}
<div
ref={scrollContainerRef}
className="flex gap-3 overflow-x-auto px-4 sm:px-6 py-3 snap-x snap-mandatory"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
WebkitOverflowScrolling: 'touch',
}}
role="listbox"
aria-label="Card candidates"
>
{disambiguation.candidates.map((candidate) => (
<button
type="button"
key={candidate.id}
onClick={() => onPick(candidate)}
className="flex-shrink-0 w-32 rounded-xl overflow-hidden text-left transition-all duration-150 snap-start focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-tertiary)',
border: '2px solid transparent',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
minHeight: '44px',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
role="option"
aria-selected={false}
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
>
{candidate.image_url ? (
<img
src={candidate.image_url}
alt=""
className="w-full aspect-[5/7] object-cover"
loading="lazy"
/>
) : (
<div
className="w-full aspect-[5/7] flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} 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 className="p-2">
<div className="font-medium text-xs truncate" style={{ color: 'var(--text-primary)' }}>
{candidate.name}
</div>
<div className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
{[candidate.set_name, candidate.card_number].filter(Boolean).join(' · ')}
</div>
</div>
</button>
))}
</div>
{/* Action buttons */}
<div className="px-4 sm:px-6 pb-4" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
<button
type="button"
onClick={onNotInCatalog}
disabled={submittingReview}
className="w-full py-3 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minHeight: '44px',
}}
>
{submittingReview ? 'Submitting...' : 'Not listed \u2014 send for review'}
</button>
<button
type="button"
onClick={onCancel}
className="w-full py-3 mt-2 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-70"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
minHeight: '44px',
}}
>
Cancel
</button>
</div>
</div>
<style jsx>{`
.disambiguation-backdrop {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
-webkit-backdrop-filter 300ms ease-out;
}
.disambiguation-backdrop[data-visible='true'] {
background: var(--modal-scrim);
backdrop-filter: blur(var(--glass-blur-high));
-webkit-backdrop-filter: blur(var(--glass-blur-high));
}
.disambiguation-sheet {
max-height: 60vh;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.disambiguation-sheet[data-visible='true'] {
transform: translateY(0);
}
div[role='listbox']::-webkit-scrollbar {
display: none;
}
`}</style>
</div>
);
}