deckhearth/components/ScannedCardItem.js

302 lines
11 KiB
JavaScript
Raw Normal View History

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 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}
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>
);
}