deckhearth/components/ScannedCardItem.js
Randall Stillwell 5a1a7295d1 fix(scanner): close redesign a11y audit findings
Add focus traps for modals, accessible names for icon/select controls,
ownership badge role=status, list semantics for the scan queue, and
aria-live updates for the card count.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:13:09 -05:00

298 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

import { useEffect, useState } from 'react';
export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
export default function ScannedCardItem({
card,
collections,
decks,
selected,
onToggleSelect,
onIncrement,
onDecrement,
onUpdateMetadata,
onMarkOwned,
onAddToCollection,
onAddToDeck,
onRemove,
}) {
const [ownedQuantity, setOwnedQuantity] = useState(null);
useEffect(() => {
if (!card.databaseId) {
setOwnedQuantity(null);
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(`/api/cards/${card.databaseId}/ownership`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) return;
const data = await response.json();
if (!cancelled) {
setOwnedQuantity(typeof data.quantity === 'number' ? data.quantity : 0);
}
} catch {
if (!cancelled) setOwnedQuantity(null);
}
})();
return () => {
cancelled = true;
};
}, [card.databaseId]);
return (
<div
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: selected ? 'var(--accent-ember)' : 'var(--border)',
borderWidth: selected ? '2px' : '1px',
}}
>
<div className="relative flex-shrink-0">
<div className="w-20 h-28 rounded-lg overflow-hidden flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-secondary)' }}>
{card.image_url ? (
<img src={card.image_url} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs p-2" style={{ color: 'var(--text-secondary)' }}>
<div className="text-2xl mb-1">🃏</div>
<div>No Image</div>
</div>
)}
</div>
{!card.processed && (
<div className="absolute top-1 left-1">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
className="w-5 h-5 rounded border-2 border-white shadow-lg"
style={{ accentColor: 'var(--accent-ember)' }}
aria-label={`Select ${card.name}`}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
{card.confidence && (
<div
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
style={{
backgroundColor:
card.confidence >= 90
? 'var(--accent-gold)'
: card.confidence >= 70
? 'var(--accent-ember)'
: 'var(--text-secondary)',
color: 'white',
}}
>
{Math.round(card.confidence)}% confidence
</div>
)}
<div className="flex items-start justify-between mb-2 gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
{card.isExisting && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
<span aria-hidden="true"> </span>
<span>Found in database</span>
</div>
)}
{ownedQuantity !== null && ownedQuantity > 0 && (
<div
role="status"
className="text-xs font-medium mt-1"
style={{ color: 'var(--text-secondary)' }}
aria-label={`You already own ${ownedQuantity} copies of this card`}
>
You own {ownedQuantity}
</div>
)}
</div>
{!card.processed && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={onDecrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
aria-label="Decrease quantity"
>
</button>
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
{card.quantity || 1}
</span>
<button
type="button"
onClick={onIncrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
aria-label="Increase quantity"
>
+
</button>
</div>
)}
</div>
{!card.processed && (
<div className="flex flex-wrap items-center gap-3 mb-3">
<label className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>Condition</span>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="px-2 py-1 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{CONDITION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={Boolean(card.isFoil)}
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
className="rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
)}
<div className="space-y-1 mb-3">
{card.set && (
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Set:</span> {card.set}
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
</div>
)}
{card.cardNumber && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Number:</span> {card.cardNumber}
</div>
)}
{!card.processed && (card.condition || card.isFoil) && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Adding as:</span>{' '}
{card.condition || 'NM'}
{card.isFoil ? ' · Foil' : ''}
</div>
)}
</div>
{!card.processed ? (
<div className="space-y-2">
<div className="flex gap-2">
<button
type="button"
onClick={onMarkOwned}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
<span aria-hidden="true">💎 </span>
Mark Owned
</button>
<button
type="button"
onClick={onRemove}
className="px-3 py-2 rounded text-sm hover:opacity-80"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
aria-label={`Remove ${card.name} from scan queue`}
>
<span aria-hidden="true">🗑</span>
</button>
</div>
<div className="flex gap-2">
{collections.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToCollection(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a collection`}
>
<option value="">📚 Add to Collection</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToDeck(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a deck`}
>
<option value="">🃏 Add to Deck</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
)}
</div>
</div>
) : (
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
<span aria-hidden="true"></span>
<span>Added to {card.processedAction}</span>
</div>
)}
</div>
</div>
);
}