Drop alice/bob password prefill from the login page, add a regression test, and improve bulk-toolbar and disambiguation accessible names. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
import { useEffect, useState } from 'react';
|
||
import { formatProcessedDestination, VOCAB } from '../lib/collection-vocabulary.js';
|
||
|
||
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,
|
||
isAdding = false,
|
||
}) {
|
||
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
|
||
htmlFor={`scan-condition-${card.id}`}
|
||
className="flex items-center gap-2 text-sm"
|
||
style={{ color: 'var(--text-secondary)' }}
|
||
>
|
||
<span>Condition</span>
|
||
<select
|
||
id={`scan-condition-${card.id}`}
|
||
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)',
|
||
}}
|
||
aria-label={`Condition for ${card.name}`}
|
||
>
|
||
{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}
|
||
disabled={isAdding}
|
||
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||
>
|
||
<span aria-hidden="true">💎 </span>
|
||
{isAdding ? 'Adding…' : VOCAB.ADD_TO_MY_COLLECTION}
|
||
</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 list`}
|
||
>
|
||
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</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 {formatProcessedDestination(card.processedAction)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|