deckhearth/components/scanner/ReviewCardItem.js
varutasu 484bd02f9a
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>
2026-08-14 21:42:59 -05:00

382 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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'];
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)';
}
export default function ReviewCardItem({
card,
collections,
decks,
onIncrement,
onDecrement,
onUpdateMetadata,
onRemove,
onUndo,
isAdding = false,
sessionDestination,
selected = false,
onToggleSelect,
showCheckbox = false,
confidence,
}) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
const resolvedConfidence = confidence ?? card.confidence;
const confidencePct = confidencePercent(resolvedConfidence);
const destinationLabel = card.processedAction
? formatProcessedDestination(card.processedAction)
: sessionDestination?.label ?? VOCAB.MY_COLLECTION;
useEffect(() => {
if (!menuOpen) return undefined;
const handlePointerDown = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [menuOpen]);
const checkboxAriaLabel =
confidencePct != null
? `Select ${card.name}, ${confidencePct}% confidence`
: `Select ${card.name}`;
return (
<div
className={`rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : undefined,
}}
>
<div className="flex gap-3">
{showCheckbox && !card.processed && (
<label
className="flex-shrink-0 flex items-center justify-center"
style={{ minWidth: 44, minHeight: 44 }}
>
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
className="w-5 h-5 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
aria-label={checkboxAriaLabel}
/>
</label>
)}
<div
className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden"
style={{
boxShadow: showCheckbox
? `0 0 0 2px ${confidenceRingColor(confidencePct)}`
: undefined,
}}
>
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div
className="w-full h-full flex items-center justify-center rounded-lg text-xs"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
>
<svg className="w-5 h-5" 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>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{!showCheckbox && card.processed ? (
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : !showCheckbox && !card.processed ? (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: 'var(--accent-flame)' }}
aria-hidden="true"
/>
) : null}
<h3
className="font-semibold text-sm truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h3>
</div>
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
</p>
{showCheckbox && confidencePct != null && (
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{confidencePct}% match
</p>
)}
</div>
{showCheckbox && !card.processed ? (
<div className="relative flex-shrink-0" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
style={{ color: 'var(--text-secondary)', minWidth: 44, minHeight: 44 }}
aria-label={`More actions for ${card.name}`}
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01" />
</svg>
</button>
{menuOpen && (
<GlassSurface
as="div"
role="menu"
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"
role="menuitem"
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
style={{ color: 'var(--text-primary)', minHeight: 44 }}
onClick={() => {
onRemove();
setMenuOpen(false);
}}
>
Remove
</button>
<div className="px-3 py-2" role="none">
<label className="text-xs block mb-1" style={{ color: 'var(--text-secondary)' }}>
Condition
</label>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="w-full px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minHeight: 44,
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
style={{ color: 'var(--text-primary)', minHeight: 44 }}
onClick={() => onUpdateMetadata({ isFoil: !card.isFoil })}
>
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
</button>
</GlassSurface>
)}
</div>
) : !card.processed ? (
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Remove ${card.name} from scan queue`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
) : null}
</div>
{card.processed ? (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Added to {destinationLabel}
</span>
{onUndo && (
<button
type="button"
onClick={onUndo}
className="text-xs font-medium underline transition-opacity duration-150 hover:opacity-70"
style={{ color: 'var(--accent-ember)', minHeight: '44px', display: 'inline-flex', alignItems: 'center' }}
aria-label={`Undo adding ${card.name}`}
>
Undo
</button>
)}
</div>
) : (
<>
{!showCheckbox && (
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
</p>
)}
{!showCheckbox && (
<div className="flex flex-col sm:flex-row gap-2 mt-2">
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="sr-only">Condition for {card.name}</span>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minHeight: '44px',
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</label>
<label
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
>
<input
type="checkbox"
checked={Boolean(card.isFoil)}
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
className="w-4 h-4 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span>Foil</span>
</label>
</div>
)}
<div className="inline-flex items-center gap-1 mt-2">
<button
type="button"
onClick={onDecrement}
disabled={isAdding || (card.quantity || 1) <= 1}
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minWidth: '44px',
minHeight: '44px',
}}
aria-label={`Decrease quantity of ${card.name}`}
>
</button>
<span
className="min-w-[1.5rem] text-center text-sm font-medium"
style={{ color: 'var(--text-primary)' }}
aria-live="polite"
aria-label={`Quantity: ${card.quantity || 1}`}
>
{card.quantity || 1}
</span>
<button
type="button"
onClick={onIncrement}
disabled={isAdding}
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'var(--text-primary-dark)',
minWidth: '44px',
minHeight: '44px',
}}
aria-label={`Increase quantity of ${card.name}`}
>
+
</button>
</div>
{!showCheckbox && collections?.length > 0 && (
<div className="mt-2">
<select
value={card.destinationOverride || ''}
onChange={(e) => {
const val = e.target.value;
if (!val) {
onUpdateMetadata({ destinationOverride: null });
} else {
onUpdateMetadata({ destinationOverride: val });
}
}}
className="w-full px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
minHeight: '44px',
}}
aria-label={`Change destination for ${card.name}`}
>
<option value="">
{sessionDestination ? `Default (${sessionDestination.label})` : 'Change destination'}
</option>
{collections.map((c) => (
<option key={`col-${c.id}`} value={`collection:${c.id}`}>
{c.name}
</option>
))}
{decks?.map((d) => (
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
{d.name}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}