Replace stale ownership/list labels across pages and components, add lib/collection-vocabulary.js as the single copy source, document the taxonomy in AGENTS.md, and gate retired strings in CI. Co-authored-by: Cursor <cursoragent@cursor.com>
824 lines
No EOL
28 KiB
JavaScript
824 lines
No EOL
28 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { useRouter } from 'next/router';
|
||
import Layout from '../components/Layout';
|
||
import CameraScanner from '../components/CameraScanner';
|
||
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
||
import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem';
|
||
import OCRSettings from '../components/OCRSettings';
|
||
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||
import { useAuth } from '../lib/use-auth';
|
||
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||
|
||
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||
|
||
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
|
||
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
||
|
||
function loadSavedScannerSession() {
|
||
if (typeof window === 'undefined') {
|
||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
||
}
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
||
return {
|
||
destination: saved?.destination || DEFAULT_DESTINATION,
|
||
gameFilter: saved?.gameFilter || 'All',
|
||
scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS,
|
||
};
|
||
} catch {
|
||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
||
}
|
||
}
|
||
|
||
function destinationActionKey(destination) {
|
||
if (!destination) return 'pending';
|
||
return destination.type === 'owned' ? 'owned' : destination.type;
|
||
}
|
||
|
||
export default function Scanner() {
|
||
const { user, loading: authLoading } = 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 createCollectionDialogRef = useFocusTrap(showCreateCollection);
|
||
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);
|
||
const addingInFlightRef = useRef(new Set());
|
||
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
||
const [sessionDestination, setSessionDestination] = useState(
|
||
() => loadSavedScannerSession().destination
|
||
);
|
||
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
||
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
||
const [autoRouteError, setAutoRouteError] = useState(null);
|
||
|
||
// Mana symbol settings
|
||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||
|
||
// Redirect to login if not authenticated (wait for verify to finish)
|
||
useEffect(() => {
|
||
if (!authLoading && !user) {
|
||
router.push('/login');
|
||
}
|
||
}, [authLoading, user, router]);
|
||
|
||
// Load collections and decks
|
||
useEffect(() => {
|
||
if (user) {
|
||
loadCollections();
|
||
loadDecks();
|
||
}
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
localStorage.setItem(
|
||
SESSION_STORAGE_KEY,
|
||
JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults })
|
||
);
|
||
}, [sessionDestination, gameFilter, scanDefaults]);
|
||
|
||
const handleGameFilterChange = (nextFilter) => {
|
||
setGameFilter(nextFilter);
|
||
setSessionDestination((current) => {
|
||
if (!current || current.type === 'owned') return current;
|
||
return DEFAULT_DESTINATION;
|
||
});
|
||
};
|
||
|
||
const routeCardToDestination = async (card, destination) => {
|
||
if (!destination || !card.databaseId) return false;
|
||
|
||
if (destination.type === 'owned') {
|
||
await addToOwnedCards(card);
|
||
} else if (destination.type === 'collection') {
|
||
await addToCollection(card, destination.id);
|
||
} else if (destination.type === 'deck') {
|
||
await addToDeck(card, destination.id);
|
||
} else {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
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 auto-sync system lists (not user-curated)
|
||
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) => {
|
||
setAutoRouteError(null);
|
||
|
||
const existingCardIndex = scannedCards.findIndex((existing) =>
|
||
existing.name === cardData.name &&
|
||
existing.set === cardData.set &&
|
||
!existing.processed
|
||
);
|
||
|
||
let cardEntry;
|
||
|
||
if (existingCardIndex !== -1) {
|
||
const existing = scannedCards[existingCardIndex];
|
||
cardEntry = {
|
||
...existing,
|
||
quantity: (existing.quantity || 1) + 1,
|
||
scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl,
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
setScannedCards((prev) => {
|
||
const updated = [...prev];
|
||
updated[existingCardIndex] = cardEntry;
|
||
return updated;
|
||
});
|
||
} else {
|
||
cardEntry = {
|
||
...cardData,
|
||
id: Date.now() + Math.random(),
|
||
name: cardData.name,
|
||
set: cardData.set,
|
||
quantity: 1,
|
||
condition: scanDefaults.condition,
|
||
isFoil: scanDefaults.isFoil,
|
||
timestamp: new Date().toISOString(),
|
||
processed: false,
|
||
};
|
||
setScannedCards((prev) => [cardEntry, ...prev]);
|
||
}
|
||
|
||
if (!sessionDestination) return;
|
||
|
||
if (!cardEntry.databaseId) {
|
||
setAutoRouteError(`"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (!tryBeginAdding(cardEntry.id)) return;
|
||
await routeCardToDestination(cardEntry, sessionDestination);
|
||
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
|
||
} catch (error) {
|
||
console.error('Auto-route failed:', error);
|
||
setAutoRouteError(`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`);
|
||
} finally {
|
||
endAdding(cardEntry.id);
|
||
}
|
||
};
|
||
|
||
// 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
|
||
};
|
||
|
||
const syncAddingState = () => {
|
||
setAddingCardIds(new Set(addingInFlightRef.current));
|
||
};
|
||
|
||
const tryBeginAdding = (cardId) => {
|
||
if (addingInFlightRef.current.has(cardId)) return false;
|
||
addingInFlightRef.current.add(cardId);
|
||
syncAddingState();
|
||
return true;
|
||
};
|
||
|
||
const endAdding = (cardId) => {
|
||
addingInFlightRef.current.delete(cardId);
|
||
syncAddingState();
|
||
};
|
||
|
||
// Individual card actions
|
||
const addSingleCardToOwned = async (card) => {
|
||
if (!tryBeginAdding(card.id)) return;
|
||
try {
|
||
await addToOwnedCards(card);
|
||
markCardAsProcessed(card.id, 'owned');
|
||
} catch (error) {
|
||
console.error('Error adding card to owned:', error);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
};
|
||
|
||
const addSingleCardToCollection = async (card, collectionId) => {
|
||
if (!tryBeginAdding(card.id)) return;
|
||
try {
|
||
await addToCollection(card, collectionId);
|
||
markCardAsProcessed(card.id, 'collection');
|
||
} catch (error) {
|
||
console.error('Error adding card to collection:', error);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
};
|
||
|
||
const addSingleCardToDeck = async (card, deckId) => {
|
||
if (!tryBeginAdding(card.id)) return;
|
||
try {
|
||
await addToDeck(card, deckId);
|
||
markCardAsProcessed(card.id, 'deck');
|
||
} catch (error) {
|
||
console.error('Error adding card to deck:', error);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
};
|
||
|
||
// Bulk actions
|
||
const handleBulkAction = async (actionOverride, targetOverride) => {
|
||
const action = actionOverride ?? bulkAction;
|
||
const target = targetOverride ?? bulkTarget;
|
||
if (!action || selectedCards.size === 0) return;
|
||
|
||
setIsProcessing(true);
|
||
try {
|
||
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
|
||
|
||
for (const card of cardsToProcess) {
|
||
if (action === 'owned') {
|
||
if (!tryBeginAdding(card.id)) continue;
|
||
try {
|
||
await addToOwnedCards(card);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
} else if (action === 'collection' && target) {
|
||
if (!tryBeginAdding(card.id)) continue;
|
||
try {
|
||
await addToCollection(card, target);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
} else if (action === 'deck' && target) {
|
||
if (!tryBeginAdding(card.id)) continue;
|
||
try {
|
||
await addToDeck(card, target);
|
||
} finally {
|
||
endAdding(card.id);
|
||
}
|
||
} else {
|
||
continue;
|
||
}
|
||
markCardAsProcessed(card.id, action);
|
||
}
|
||
|
||
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
|
||
));
|
||
};
|
||
|
||
const updateCardMetadata = (cardId, patch) => {
|
||
setScannedCards((prev) =>
|
||
prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card))
|
||
);
|
||
};
|
||
|
||
const buildCardPayload = (cardData) => {
|
||
const payload = {
|
||
cardId: cardData.databaseId,
|
||
quantity: cardData.quantity || 1,
|
||
condition: cardData.condition || 'NM',
|
||
is_foil: Boolean(cardData.isFoil),
|
||
};
|
||
|
||
if (cardData.scanImageUrl) {
|
||
payload.scan_image_url = cardData.scanImageUrl;
|
||
}
|
||
|
||
return payload;
|
||
};
|
||
|
||
// 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(buildCardPayload(cardData)),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`);
|
||
}
|
||
};
|
||
|
||
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(buildCardPayload(cardData)),
|
||
});
|
||
|
||
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(buildCardPayload(cardData)),
|
||
});
|
||
|
||
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 (authLoading) {
|
||
return (
|
||
<Layout user={null}>
|
||
<div className="flex items-center justify-center min-h-[50vh]">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--text-accent)' }} />
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
if (!user) {
|
||
return <div>Redirecting to login...</div>;
|
||
}
|
||
|
||
return (
|
||
<Layout user={user}>
|
||
<div className="h-full flex flex-col">
|
||
{/* Header */}
|
||
<div className="px-6 pt-6 pb-4">
|
||
<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)' }}>
|
||
Pick a destination once — every scan lands there until you change it
|
||
</p>
|
||
</div>
|
||
|
||
<ScannerDestinationPicker
|
||
gameFilter={gameFilter}
|
||
onGameFilterChange={handleGameFilterChange}
|
||
destination={sessionDestination}
|
||
onDestinationChange={setSessionDestination}
|
||
collections={collections}
|
||
decks={decks}
|
||
disabled={isProcessing}
|
||
/>
|
||
|
||
{autoRouteError && (
|
||
<div
|
||
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
|
||
style={{
|
||
backgroundColor: 'var(--bg-secondary)',
|
||
borderColor: 'var(--accent-flame)',
|
||
color: 'var(--text-primary)',
|
||
}}
|
||
role="alert"
|
||
>
|
||
{autoRouteError}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className="mx-6 mb-4 rounded-xl border p-4 flex flex-wrap items-end gap-4"
|
||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||
>
|
||
<div>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||
Defaults for new scans
|
||
</h2>
|
||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||
Applied to each card when it enters the queue
|
||
</p>
|
||
</div>
|
||
<label className="flex flex-col gap-1 text-sm">
|
||
<span style={{ color: 'var(--text-secondary)' }}>Condition</span>
|
||
<select
|
||
value={scanDefaults.condition}
|
||
onChange={(e) => setScanDefaults((current) => ({ ...current, condition: e.target.value }))}
|
||
className="px-3 py-2 rounded-lg border"
|
||
style={{
|
||
backgroundColor: 'var(--bg-tertiary)',
|
||
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 pb-2 cursor-pointer" style={{ color: 'var(--text-primary)' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={scanDefaults.isFoil}
|
||
onChange={(e) => setScanDefaults((current) => ({ ...current, isFoil: e.target.checked }))}
|
||
style={{ accentColor: 'var(--accent-ember)' }}
|
||
/>
|
||
Foil
|
||
</label>
|
||
</div>
|
||
|
||
{/* Main Content - Full Height */}
|
||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
|
||
{/* Camera Scanner */}
|
||
<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)' }}>
|
||
<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>
|
||
|
||
<div className="flex-1">
|
||
<CameraScanner
|
||
onCardScanned={handleCardScanned}
|
||
onError={handleError}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scanned Cards Queue */}
|
||
<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)' }}>
|
||
<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)' }}
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
>
|
||
{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>
|
||
|
||
{/* Scanned Cards Queue - Scrollable */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{scannedCards.length === 0 ? (
|
||
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
||
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
|
||
<div className="font-medium">No cards scanned yet</div>
|
||
<div className="text-sm">Start scanning to see cards here</div>
|
||
</div>
|
||
) : (
|
||
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
|
||
{scannedCards.map((card) => (
|
||
<li key={card.id}>
|
||
<ScannedCardItem
|
||
card={card}
|
||
collections={collections}
|
||
decks={decks}
|
||
selected={selectedCards.has(card.id)}
|
||
onToggleSelect={() => toggleCardSelection(card.id)}
|
||
onIncrement={() => incrementCardQuantity(card.id)}
|
||
onDecrement={() => decrementCardQuantity(card.id)}
|
||
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
||
onMarkOwned={() => addSingleCardToOwned(card)}
|
||
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
||
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
||
onRemove={() => removeScannedCard(card.id)}
|
||
isAdding={addingCardIds.has(card.id)}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</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={() => handleBulkAction('owned')}
|
||
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' }}
|
||
>
|
||
<span aria-hidden="true">💎 </span>
|
||
{VOCAB.ADD_TO_MY_COLLECTION}
|
||
</button>
|
||
|
||
{collections.length > 0 && (
|
||
<select
|
||
onChange={(e) => {
|
||
const collectionId = e.target.value;
|
||
e.target.value = '';
|
||
if (collectionId) {
|
||
handleBulkAction('collection', collectionId);
|
||
}
|
||
}}
|
||
disabled={isProcessing}
|
||
className="px-4 py-2 rounded-lg font-medium"
|
||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
||
aria-label="Add selected cards to 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 deckId = e.target.value;
|
||
e.target.value = '';
|
||
if (deckId) {
|
||
handleBulkAction('deck', deckId);
|
||
}
|
||
}}
|
||
disabled={isProcessing}
|
||
className="px-4 py-2 rounded-lg font-medium"
|
||
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
||
aria-label="Add selected cards to deck"
|
||
>
|
||
<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)' }}
|
||
aria-label="Clear selection"
|
||
>
|
||
<span aria-hidden="true">✕</span>
|
||
</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
|
||
ref={createCollectionDialogRef}
|
||
className="rounded-xl p-6 max-w-md w-full mx-4"
|
||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="create-collection-title"
|
||
>
|
||
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||
Create New Collection
|
||
</h3>
|
||
<label htmlFor="create-collection-name" className="sr-only">
|
||
Collection name
|
||
</label>
|
||
<input
|
||
id="create-collection-name"
|
||
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>
|
||
);
|
||
}
|