2026-05-27 14:54:33 -04:00
|
|
|
|
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,
|
2026-05-28 15:45:55 -04:00
|
|
|
|
isAdding = false,
|
2026-05-27 14:54:33 -04:00
|
|
|
|
}) {
|
|
|
|
|
|
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)' }}>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<span aria-hidden="true">✅ </span>
|
|
|
|
|
|
<span>Found in database</span>
|
2026-05-27 14:54:33 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{ownedQuantity !== null && ownedQuantity > 0 && (
|
|
|
|
|
|
<div
|
2026-05-27 15:22:18 -04:00
|
|
|
|
role="status"
|
2026-05-27 14:54:33 -04:00
|
|
|
|
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}
|
2026-05-28 15:45:55 -04:00
|
|
|
|
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"
|
2026-05-27 14:54:33 -04:00
|
|
|
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
|
|
|
|
|
>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<span aria-hidden="true">💎 </span>
|
2026-05-28 15:45:55 -04:00
|
|
|
|
{isAdding ? 'Adding…' : 'Mark Owned'}
|
2026-05-27 14:54:33 -04:00
|
|
|
|
</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)' }}
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label={`Remove ${card.name} from scan queue`}
|
2026-05-27 14:54:33 -04:00
|
|
|
|
>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<span aria-hidden="true">🗑️</span>
|
2026-05-27 14:54:33 -04:00
|
|
|
|
</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=""
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label={`Add ${card.name} to a collection`}
|
2026-05-27 14:54:33 -04:00
|
|
|
|
>
|
|
|
|
|
|
<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=""
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label={`Add ${card.name} to a deck`}
|
2026-05-27 14:54:33 -04:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|