2025-07-29 15:19:48 -04:00
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Layout from '../components/Layout';
|
|
|
|
|
|
import CameraScanner from '../components/CameraScanner';
|
|
|
|
|
|
import OCRSettings from '../components/OCRSettings';
|
|
|
|
|
|
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
|
|
|
|
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
|
|
|
|
|
import { useAuth } from '../lib/auth-context';
|
|
|
|
|
|
|
|
|
|
|
|
export default function Scanner() {
|
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const [scannedCards, setScannedCards] = useState([]);
|
|
|
|
|
|
const [collections, setCollections] = useState([]);
|
|
|
|
|
|
const [decks, setDecks] = useState([]);
|
|
|
|
|
|
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
|
|
|
|
|
const [newCollectionName, setNewCollectionName] = useState('');
|
|
|
|
|
|
const [showOCRSettings, setShowOCRSettings] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Bulk action states
|
|
|
|
|
|
const [selectedCards, setSelectedCards] = useState(new Set());
|
|
|
|
|
|
const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck'
|
|
|
|
|
|
const [bulkTarget, setBulkTarget] = useState('');
|
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Mana symbol settings
|
|
|
|
|
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
|
|
|
|
|
|
|
|
|
|
// Redirect to login if not authenticated
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
router.push('/login');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user, router]);
|
|
|
|
|
|
|
|
|
|
|
|
// Load collections and decks
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
loadCollections();
|
|
|
|
|
|
loadDecks();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadCollections = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/collections', {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
// Filter out system collections (like "All My Cards")
|
|
|
|
|
|
const userCollections = data.filter(collection => !collection.is_system_collection);
|
|
|
|
|
|
setCollections(userCollections);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading collections:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadDecks = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/decks', {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
setDecks(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading decks:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCardScanned = async (cardData) => {
|
|
|
|
|
|
console.log('Card scanned:', cardData);
|
2025-08-01 18:41:42 -04:00
|
|
|
|
console.log('Looking for existing card with name:', cardData.name, 'set:', cardData.set);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
|
|
// Check if this card already exists in the queue
|
|
|
|
|
|
setScannedCards(prev => {
|
2025-08-01 18:41:42 -04:00
|
|
|
|
console.log('Current queue:', prev.map(c => ({ name: c.name, set: c.set, processed: c.processed })));
|
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
const existingCardIndex = prev.findIndex(existing =>
|
2025-08-01 18:41:42 -04:00
|
|
|
|
existing.name === cardData.name &&
|
|
|
|
|
|
existing.set === cardData.set &&
|
2025-07-29 15:19:48 -04:00
|
|
|
|
!existing.processed
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (existingCardIndex !== -1) {
|
2025-08-01 18:41:42 -04:00
|
|
|
|
console.log(`📈 Incrementing quantity for existing card: ${cardData.name}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
// Increment quantity of existing card
|
|
|
|
|
|
const updatedCards = [...prev];
|
|
|
|
|
|
updatedCards[existingCardIndex] = {
|
|
|
|
|
|
...updatedCards[existingCardIndex],
|
|
|
|
|
|
quantity: (updatedCards[existingCardIndex].quantity || 1) + 1,
|
|
|
|
|
|
timestamp: new Date().toISOString() // Update timestamp
|
|
|
|
|
|
};
|
|
|
|
|
|
return updatedCards;
|
|
|
|
|
|
} else {
|
2025-08-01 18:41:42 -04:00
|
|
|
|
console.log(`🆕 Adding new card to queue: ${cardData.name}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
// Add new card to queue
|
|
|
|
|
|
const scannedCard = {
|
|
|
|
|
|
...cardData,
|
2025-08-01 18:41:42 -04:00
|
|
|
|
id: Date.now() + Math.random(), // More unique ID for the queue
|
|
|
|
|
|
name: cardData.name,
|
|
|
|
|
|
set: cardData.set,
|
2025-07-29 15:19:48 -04:00
|
|
|
|
quantity: 1,
|
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
processed: false
|
|
|
|
|
|
};
|
|
|
|
|
|
return [scannedCard, ...prev];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Quantity management functions
|
|
|
|
|
|
const incrementCardQuantity = (cardId) => {
|
|
|
|
|
|
setScannedCards(prev => prev.map(card =>
|
|
|
|
|
|
card.id === cardId
|
|
|
|
|
|
? { ...card, quantity: (card.quantity || 1) + 1 }
|
|
|
|
|
|
: card
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const decrementCardQuantity = (cardId) => {
|
|
|
|
|
|
setScannedCards(prev => prev.map(card =>
|
|
|
|
|
|
card.id === cardId
|
|
|
|
|
|
? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) }
|
|
|
|
|
|
: card
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleError = (error) => {
|
|
|
|
|
|
console.error('Scanner error:', error);
|
|
|
|
|
|
// You could show a toast notification here
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Individual card actions
|
|
|
|
|
|
const addSingleCardToOwned = async (card) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addToOwnedCards(card);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'owned');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to owned:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSingleCardToCollection = async (card, collectionId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addToCollection(card, collectionId);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'collection');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to collection:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSingleCardToDeck = async (card, deckId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addToDeck(card, deckId);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'deck');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to deck:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bulk actions
|
|
|
|
|
|
const handleBulkAction = async () => {
|
|
|
|
|
|
if (!bulkAction || selectedCards.size === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
|
|
|
|
|
|
|
|
|
|
|
|
for (const card of cardsToProcess) {
|
|
|
|
|
|
if (bulkAction === 'owned') {
|
|
|
|
|
|
await addToOwnedCards(card);
|
|
|
|
|
|
} else if (bulkAction === 'collection' && bulkTarget) {
|
|
|
|
|
|
await addToCollection(card, bulkTarget);
|
|
|
|
|
|
} else if (bulkAction === 'deck' && bulkTarget) {
|
|
|
|
|
|
await addToDeck(card, bulkTarget);
|
|
|
|
|
|
}
|
|
|
|
|
|
markCardAsProcessed(card.id, bulkAction);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear selections and reset bulk action state
|
|
|
|
|
|
setSelectedCards(new Set());
|
|
|
|
|
|
setBulkAction('');
|
|
|
|
|
|
setBulkTarget('');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error processing bulk action:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const markCardAsProcessed = (cardId, action) => {
|
|
|
|
|
|
setScannedCards(prev => prev.map(card =>
|
|
|
|
|
|
card.id === cardId
|
|
|
|
|
|
? { ...card, processed: true, processedAction: action }
|
|
|
|
|
|
: card
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Helper functions for API calls
|
|
|
|
|
|
const addToOwnedCards = async (cardData) => {
|
|
|
|
|
|
const response = await fetch('/api/user-cards', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
cardId: cardData.databaseId,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
condition: 'NM'
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to add to owned cards');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addToCollection = async (cardData, collectionId) => {
|
|
|
|
|
|
const response = await fetch(`/api/collections/${collectionId}/cards`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
cardId: cardData.databaseId,
|
|
|
|
|
|
quantity: 1
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to add to collection');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addToDeck = async (cardData, deckId) => {
|
|
|
|
|
|
const response = await fetch(`/api/decks/${deckId}/cards`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
cardId: cardData.databaseId,
|
|
|
|
|
|
quantity: 1
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to add to deck');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createCollection = async () => {
|
|
|
|
|
|
if (!newCollectionName.trim()) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/collections', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
name: newCollectionName,
|
|
|
|
|
|
description: 'Created from card scanner',
|
|
|
|
|
|
is_public: false
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const newCollection = await response.json();
|
|
|
|
|
|
setCollections(prev => [newCollection, ...prev]);
|
|
|
|
|
|
setBulkTarget(newCollection.id.toString());
|
|
|
|
|
|
setNewCollectionName('');
|
|
|
|
|
|
setShowCreateCollection(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error creating collection:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clearScannedCards = () => {
|
|
|
|
|
|
setScannedCards([]);
|
|
|
|
|
|
setSelectedCards(new Set());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeScannedCard = (cardId) => {
|
|
|
|
|
|
setScannedCards(prev => prev.filter(card => card.id !== cardId));
|
|
|
|
|
|
setSelectedCards(prev => {
|
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
|
newSet.delete(cardId);
|
|
|
|
|
|
return newSet;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleCardSelection = (cardId) => {
|
|
|
|
|
|
setSelectedCards(prev => {
|
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
|
if (newSet.has(cardId)) {
|
|
|
|
|
|
newSet.delete(cardId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSet.add(cardId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return newSet;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const selectAllCards = () => {
|
|
|
|
|
|
const unprocessedCards = scannedCards.filter(card => !card.processed);
|
|
|
|
|
|
setSelectedCards(new Set(unprocessedCards.map(card => card.id)));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deselectAllCards = () => {
|
|
|
|
|
|
setSelectedCards(new Set());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
return <div>Redirecting to login...</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout>
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="h-full flex flex-col">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{/* Header */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="px-6 pt-6 pb-4">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
🃏 Card Scanner
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
Scan cards to identify them, then choose what to do with your collection
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
{/* Main Content - Full Height */}
|
|
|
|
|
|
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{/* Camera Scanner */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="lg:col-span-3 flex flex-col">
|
|
|
|
|
|
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
|
|
|
|
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Camera Scanner
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowOCRSettings(true)}
|
|
|
|
|
|
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-primary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
⚙️ OCR Settings
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<CameraScanner
|
|
|
|
|
|
onCardScanned={handleCardScanned}
|
|
|
|
|
|
onError={handleError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Scanned Cards Queue */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="lg:col-span-2 flex flex-col">
|
|
|
|
|
|
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
|
|
|
|
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Scanned Cards
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
{scannedCards.length} cards
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{scannedCards.length > 0 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={clearScannedCards}
|
|
|
|
|
|
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-secondary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear All
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
{/* Scanned Cards Queue - Scrollable */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
|
|
|
<div className="space-y-3">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{scannedCards.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
<div className="text-4xl mb-2">📱</div>
|
|
|
|
|
|
<div className="font-medium">No cards scanned yet</div>
|
|
|
|
|
|
<div className="text-sm">Start scanning to see cards here</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
scannedCards.map((card) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={card.id}
|
|
|
|
|
|
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
|
|
|
|
borderColor: selectedCards.has(card.id) ? 'var(--accent-ember)' : 'var(--border)',
|
|
|
|
|
|
borderWidth: selectedCards.has(card.id) ? '2px' : '1px'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Card Thumbnail with Checkbox Overlay */}
|
|
|
|
|
|
<div className="relative flex-shrink-0">
|
|
|
|
|
|
<div className="w-20 h-28 rounded-lg overflow-hidden bg-gray-200 flex items-center justify-center">
|
|
|
|
|
|
{card.image_url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="text-center text-xs text-gray-500 p-2">
|
|
|
|
|
|
<div className="text-2xl mb-1">🃏</div>
|
|
|
|
|
|
<div>No Image</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Checkbox Overlay */}
|
|
|
|
|
|
{!card.processed && (
|
|
|
|
|
|
<div className="absolute top-1 left-1">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={selectedCards.has(card.id)}
|
|
|
|
|
|
onChange={() => toggleCardSelection(card.id)}
|
|
|
|
|
|
className="w-5 h-5 rounded border-2 border-white shadow-lg"
|
|
|
|
|
|
style={{ accentColor: 'var(--accent-ember)' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Content */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
{/* Confidence Badge */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Title and Quantity Row */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{/* Database Status */}
|
|
|
|
|
|
{card.isExisting && (
|
|
|
|
|
|
<div className="text-xs text-green-600 font-medium">
|
|
|
|
|
|
✅ Found in database
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quantity Controls */}
|
|
|
|
|
|
{!card.processed && (
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => decrementCardQuantity(card.id)}
|
|
|
|
|
|
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)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
−
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
{card.quantity || 1}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => incrementCardQuantity(card.id)}
|
|
|
|
|
|
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' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Details */}
|
|
|
|
|
|
<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.cardType && (
|
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
<span className="font-semibold">Type:</span> {card.cardType}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.rarity && (
|
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
<span className="font-semibold">Rarity:</span> {card.rarity}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.hp && (
|
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
<span className="font-semibold">HP:</span> {card.hp}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.manaCost && (
|
|
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
<span className="font-semibold">Mana Cost:</span> {card.manaCost}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.ocrText && (
|
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
|
<div className="text-xs font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
Scanned Text:
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs p-2 rounded max-h-16 overflow-y-auto"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
{card.ocrText.substring(0, 150)}{card.ocrText.length > 150 ? '...' : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
{!card.processed ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{/* Primary Actions Row */}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => addSingleCardToOwned(card)}
|
|
|
|
|
|
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' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
💎 Mark Owned
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => removeScannedCard(card.id)}
|
|
|
|
|
|
className="px-3 py-2 rounded text-sm hover:opacity-80"
|
|
|
|
|
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
|
|
|
|
|
|
title="Remove"
|
|
|
|
|
|
>
|
|
|
|
|
|
🗑️
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Secondary Actions Row */}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{collections.length > 0 && (
|
|
|
|
|
|
<select
|
|
|
|
|
|
onChange={(e) => e.target.value && addSingleCardToCollection(card, e.target.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)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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) => e.target.value && addSingleCardToDeck(card, e.target.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)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>✅</span>
|
|
|
|
|
|
<span>Added to {card.processedAction}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Floating Bulk Actions Toolbar */}
|
|
|
|
|
|
{selectedCards.size > 0 && (
|
|
|
|
|
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
|
|
|
|
|
<div className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
backdropFilter: 'blur(10px)'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Selection Count */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-ember)' }}>
|
|
|
|
|
|
{selectedCards.size}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Divider */}
|
|
|
|
|
|
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quick Actions */}
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setBulkAction('owned');
|
|
|
|
|
|
handleBulkAction();
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
💎 Mark Owned
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{collections.length > 0 && (
|
|
|
|
|
|
<select
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
if (e.target.value) {
|
|
|
|
|
|
setBulkAction('collection');
|
|
|
|
|
|
setBulkTarget(e.target.value);
|
|
|
|
|
|
setTimeout(() => handleBulkAction(), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="px-4 py-2 rounded-lg font-medium"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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) => {
|
|
|
|
|
|
if (e.target.value) {
|
|
|
|
|
|
setBulkAction('deck');
|
|
|
|
|
|
setBulkTarget(e.target.value);
|
|
|
|
|
|
setTimeout(() => handleBulkAction(), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="px-4 py-2 rounded-lg font-medium"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">🃏 Add to Deck</option>
|
|
|
|
|
|
{decks.map(deck => (
|
|
|
|
|
|
<option key={deck.id} value={deck.id}>
|
|
|
|
|
|
{deck.name} ({deck.game})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Divider */}
|
|
|
|
|
|
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Clear Selection */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setSelectedCards(new Set())}
|
|
|
|
|
|
className="px-3 py-2 rounded-lg hover:opacity-80"
|
|
|
|
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
|
|
|
|
title="Clear Selection"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bulk Actions Modal */}
|
|
|
|
|
|
{/* This modal is no longer needed as bulk actions are in a floating toolbar */}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Create Collection Modal */}
|
|
|
|
|
|
{showCreateCollection && (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
|
|
|
|
<div className="rounded-xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
|
|
|
|
|
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Create New Collection
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Collection name..."
|
|
|
|
|
|
value={newCollectionName}
|
|
|
|
|
|
onChange={(e) => setNewCollectionName(e.target.value)}
|
|
|
|
|
|
className="w-full px-4 py-2 rounded-lg border mb-4"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-primary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
onKeyPress={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
createCollection();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={createCollection}
|
|
|
|
|
|
disabled={!newCollectionName.trim()}
|
|
|
|
|
|
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Create
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowCreateCollection(false)}
|
|
|
|
|
|
className="flex-1 px-4 py-2 rounded-lg border font-medium"
|
|
|
|
|
|
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* OCR Settings Modal */}
|
|
|
|
|
|
{showOCRSettings && (
|
|
|
|
|
|
<OCRSettings onClose={() => setShowOCRSettings(false)} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|