refactor(scanner): extract useScannerQueue hook (page Brief 2) #75
2 changed files with 347 additions and 327 deletions
289
lib/use-scanner-queue.js
Normal file
289
lib/use-scanner-queue.js
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
addScannedCardToCollection,
|
||||||
|
addScannedCardToDeck,
|
||||||
|
addScannedCardToOwned,
|
||||||
|
createScannerCollection,
|
||||||
|
fetchScannerCollections,
|
||||||
|
fetchScannerDecks,
|
||||||
|
routeScannedCardToDestination,
|
||||||
|
} from './scanner-route-api.js';
|
||||||
|
import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
|
||||||
|
*/
|
||||||
|
export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
|
||||||
|
const [scannedCards, setScannedCards] = useState([]);
|
||||||
|
const [collections, setCollections] = useState([]);
|
||||||
|
const [decks, setDecks] = useState([]);
|
||||||
|
const [selectedCards, setSelectedCards] = useState(new Set());
|
||||||
|
const [bulkAction, setBulkAction] = useState('');
|
||||||
|
const [bulkTarget, setBulkTarget] = useState('');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [autoRouteError, setAutoRouteError] = useState(null);
|
||||||
|
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
||||||
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
|
|
||||||
|
const addingInFlightRef = useRef(new Set());
|
||||||
|
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [loadedCollections, loadedDecks] = await Promise.all([
|
||||||
|
fetchScannerCollections(),
|
||||||
|
fetchScannerDecks(),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setCollections(loadedCollections);
|
||||||
|
setDecks(loadedDecks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading scanner destinations:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markCardAsProcessed = (cardId, action) => {
|
||||||
|
setScannedCards((prev) =>
|
||||||
|
prev.map((card) =>
|
||||||
|
card.id === cardId ? { ...card, processed: true, processedAction: action } : card
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardScanned = async (cardData) => {
|
||||||
|
setAutoRouteError(null);
|
||||||
|
|
||||||
|
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
||||||
|
scannedCards,
|
||||||
|
cardData,
|
||||||
|
scanDefaults
|
||||||
|
);
|
||||||
|
setScannedCards(nextQueue);
|
||||||
|
|
||||||
|
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 routeScannedCardToDestination(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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 updateCardMetadata = (cardId, patch) => {
|
||||||
|
setScannedCards((prev) =>
|
||||||
|
prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSingleCardToOwned = async (card) => {
|
||||||
|
if (!tryBeginAdding(card.id)) return;
|
||||||
|
try {
|
||||||
|
await addScannedCardToOwned(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 addScannedCardToCollection(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 addScannedCardToDeck(card, deckId);
|
||||||
|
markCardAsProcessed(card.id, 'deck');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card to deck:', error);
|
||||||
|
} finally {
|
||||||
|
endAdding(card.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 addScannedCardToOwned(card);
|
||||||
|
} finally {
|
||||||
|
endAdding(card.id);
|
||||||
|
}
|
||||||
|
} else if (action === 'collection' && target) {
|
||||||
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
|
try {
|
||||||
|
await addScannedCardToCollection(card, target);
|
||||||
|
} finally {
|
||||||
|
endAdding(card.id);
|
||||||
|
}
|
||||||
|
} else if (action === 'deck' && target) {
|
||||||
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
|
try {
|
||||||
|
await addScannedCardToDeck(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 createCollection = async () => {
|
||||||
|
if (!newCollectionName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newCollection = await createScannerCollection(newCollectionName.trim());
|
||||||
|
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 next = new Set(prev);
|
||||||
|
next.delete(cardId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCardSelection = (cardId) => {
|
||||||
|
setSelectedCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cardId)) {
|
||||||
|
next.delete(cardId);
|
||||||
|
} else {
|
||||||
|
next.add(cardId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedCards(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannedCards,
|
||||||
|
collections,
|
||||||
|
decks,
|
||||||
|
selectedCards,
|
||||||
|
isProcessing,
|
||||||
|
addingCardIds,
|
||||||
|
autoRouteError,
|
||||||
|
showCreateCollection,
|
||||||
|
setShowCreateCollection,
|
||||||
|
newCollectionName,
|
||||||
|
setNewCollectionName,
|
||||||
|
handleCardScanned,
|
||||||
|
incrementCardQuantity,
|
||||||
|
decrementCardQuantity,
|
||||||
|
updateCardMetadata,
|
||||||
|
addSingleCardToOwned,
|
||||||
|
addSingleCardToCollection,
|
||||||
|
addSingleCardToDeck,
|
||||||
|
handleBulkAction,
|
||||||
|
createCollection,
|
||||||
|
clearScannedCards,
|
||||||
|
removeScannedCard,
|
||||||
|
toggleCardSelection,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
383
pages/scanner.js
383
pages/scanner.js
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import CameraScanner from '../components/CameraScanner';
|
import CameraScanner from '../components/CameraScanner';
|
||||||
|
|
@ -8,49 +8,26 @@ import OCRSettings from '../components/OCRSettings';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
import {
|
|
||||||
addScannedCardToCollection,
|
|
||||||
addScannedCardToDeck,
|
|
||||||
addScannedCardToOwned,
|
|
||||||
createScannerCollection,
|
|
||||||
fetchScannerCollections,
|
|
||||||
fetchScannerDecks,
|
|
||||||
routeScannedCardToDestination,
|
|
||||||
} from '../lib/scanner-route-api.js';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SCANNER_DESTINATION,
|
DEFAULT_SCANNER_DESTINATION,
|
||||||
destinationActionKey,
|
|
||||||
loadSavedScannerSession,
|
loadSavedScannerSession,
|
||||||
mergeScannedCardEntry,
|
|
||||||
saveScannerSession,
|
saveScannerSession,
|
||||||
} from '../lib/scanner-session.js';
|
} from '../lib/scanner-session.js';
|
||||||
|
import { useScannerQueue } from '../lib/use-scanner-queue.js';
|
||||||
|
|
||||||
export default function Scanner() {
|
export default function Scanner() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
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);
|
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(
|
const [sessionDestination, setSessionDestination] = useState(
|
||||||
() => loadSavedScannerSession().destination
|
() => loadSavedScannerSession().destination
|
||||||
);
|
);
|
||||||
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
||||||
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
||||||
const [autoRouteError, setAutoRouteError] = useState(null);
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated (wait for verify to finish)
|
const queue = useScannerQueue({ user, sessionDestination, scanDefaults });
|
||||||
|
const createCollectionDialogRef = useFocusTrap(queue.showCreateCollection);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
|
@ -61,30 +38,6 @@ export default function Scanner() {
|
||||||
saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults });
|
saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults });
|
||||||
}, [sessionDestination, gameFilter, scanDefaults]);
|
}, [sessionDestination, gameFilter, scanDefaults]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const [loadedCollections, loadedDecks] = await Promise.all([
|
|
||||||
fetchScannerCollections(),
|
|
||||||
fetchScannerDecks(),
|
|
||||||
]);
|
|
||||||
if (cancelled) return;
|
|
||||||
setCollections(loadedCollections);
|
|
||||||
setDecks(loadedDecks);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading scanner destinations:', error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleGameFilterChange = (nextFilter) => {
|
const handleGameFilterChange = (nextFilter) => {
|
||||||
setGameFilter(nextFilter);
|
setGameFilter(nextFilter);
|
||||||
setSessionDestination((current) => {
|
setSessionDestination((current) => {
|
||||||
|
|
@ -93,210 +46,8 @@ export default function Scanner() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardScanned = async (cardData) => {
|
|
||||||
setAutoRouteError(null);
|
|
||||||
|
|
||||||
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
|
||||||
scannedCards,
|
|
||||||
cardData,
|
|
||||||
scanDefaults
|
|
||||||
);
|
|
||||||
setScannedCards(nextQueue);
|
|
||||||
|
|
||||||
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 routeScannedCardToDestination(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) => {
|
const handleError = (error) => {
|
||||||
console.error('Scanner error:', 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 addScannedCardToOwned(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 addScannedCardToCollection(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 addScannedCardToDeck(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 addScannedCardToOwned(card);
|
|
||||||
} finally {
|
|
||||||
endAdding(card.id);
|
|
||||||
}
|
|
||||||
} else if (action === 'collection' && target) {
|
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
|
||||||
try {
|
|
||||||
await addScannedCardToCollection(card, target);
|
|
||||||
} finally {
|
|
||||||
endAdding(card.id);
|
|
||||||
}
|
|
||||||
} else if (action === 'deck' && target) {
|
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
|
||||||
try {
|
|
||||||
await addScannedCardToDeck(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 createCollection = async () => {
|
|
||||||
if (!newCollectionName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newCollection = await createScannerCollection(newCollectionName.trim());
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
|
|
@ -316,7 +67,6 @@ export default function Scanner() {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
|
||||||
<div className="px-6 pt-6 pb-4">
|
<div className="px-6 pt-6 pb-4">
|
||||||
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
🃏 Card Scanner
|
🃏 Card Scanner
|
||||||
|
|
@ -331,12 +81,12 @@ export default function Scanner() {
|
||||||
onGameFilterChange={handleGameFilterChange}
|
onGameFilterChange={handleGameFilterChange}
|
||||||
destination={sessionDestination}
|
destination={sessionDestination}
|
||||||
onDestinationChange={setSessionDestination}
|
onDestinationChange={setSessionDestination}
|
||||||
collections={collections}
|
collections={queue.collections}
|
||||||
decks={decks}
|
decks={queue.decks}
|
||||||
disabled={isProcessing}
|
disabled={queue.isProcessing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{autoRouteError && (
|
{queue.autoRouteError && (
|
||||||
<div
|
<div
|
||||||
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
|
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -346,7 +96,7 @@ export default function Scanner() {
|
||||||
}}
|
}}
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
{autoRouteError}
|
{queue.autoRouteError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -392,9 +142,7 @@ export default function Scanner() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Full Height */}
|
|
||||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
|
<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="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-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">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
|
@ -406,7 +154,7 @@ export default function Scanner() {
|
||||||
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
|
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--border)',
|
borderColor: 'var(--border)',
|
||||||
color: 'var(--text-primary)'
|
color: 'var(--text-primary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚙️ OCR Settings
|
⚙️ OCR Settings
|
||||||
|
|
@ -414,15 +162,11 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CameraScanner
|
<CameraScanner onCardScanned={queue.handleCardScanned} onError={handleError} />
|
||||||
onCardScanned={handleCardScanned}
|
|
||||||
onError={handleError}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scanned Cards Queue */}
|
|
||||||
<div className="lg:col-span-2 flex flex-col">
|
<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-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">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
|
@ -436,15 +180,15 @@ export default function Scanner() {
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
{scannedCards.length} cards
|
{queue.scannedCards.length} cards
|
||||||
</div>
|
</div>
|
||||||
{scannedCards.length > 0 && (
|
{queue.scannedCards.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearScannedCards}
|
onClick={queue.clearScannedCards}
|
||||||
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
|
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--border)',
|
borderColor: 'var(--border)',
|
||||||
color: 'var(--text-secondary)'
|
color: 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
aria-label="Clear all scanned cards from queue"
|
aria-label="Clear all scanned cards from queue"
|
||||||
>
|
>
|
||||||
|
|
@ -454,9 +198,8 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scanned Cards Queue - Scrollable */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{scannedCards.length === 0 ? (
|
{queue.scannedCards.length === 0 ? (
|
||||||
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
|
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
|
||||||
<div className="font-medium">No cards scanned yet</div>
|
<div className="font-medium">No cards scanned yet</div>
|
||||||
|
|
@ -464,22 +207,22 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
|
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
|
||||||
{scannedCards.map((card) => (
|
{queue.scannedCards.map((card) => (
|
||||||
<li key={card.id}>
|
<li key={card.id}>
|
||||||
<ScannedCardItem
|
<ScannedCardItem
|
||||||
card={card}
|
card={card}
|
||||||
collections={collections}
|
collections={queue.collections}
|
||||||
decks={decks}
|
decks={queue.decks}
|
||||||
selected={selectedCards.has(card.id)}
|
selected={queue.selectedCards.has(card.id)}
|
||||||
onToggleSelect={() => toggleCardSelection(card.id)}
|
onToggleSelect={() => queue.toggleCardSelection(card.id)}
|
||||||
onIncrement={() => incrementCardQuantity(card.id)}
|
onIncrement={() => queue.incrementCardQuantity(card.id)}
|
||||||
onDecrement={() => decrementCardQuantity(card.id)}
|
onDecrement={() => queue.decrementCardQuantity(card.id)}
|
||||||
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
|
||||||
onMarkOwned={() => addSingleCardToOwned(card)}
|
onMarkOwned={() => queue.addSingleCardToOwned(card)}
|
||||||
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
onAddToCollection={(collectionId) => queue.addSingleCardToCollection(card, collectionId)}
|
||||||
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
onAddToDeck={(deckId) => queue.addSingleCardToDeck(card, deckId)}
|
||||||
onRemove={() => removeScannedCard(card.id)}
|
onRemove={() => queue.removeScannedCard(card.id)}
|
||||||
isAdding={addingCardIds.has(card.id)}
|
isAdding={queue.addingCardIds.has(card.id)}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -490,8 +233,7 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Bulk Actions Toolbar */}
|
{queue.selectedCards.size > 0 && (
|
||||||
{selectedCards.size > 0 && (
|
|
||||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
|
className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
|
||||||
|
|
@ -503,26 +245,24 @@ export default function Scanner() {
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Selection Count */}
|
|
||||||
<div className="flex items-center gap-2" aria-live="polite" aria-atomic="true">
|
<div className="flex items-center gap-2" aria-live="polite" aria-atomic="true">
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
|
<div
|
||||||
style={{ backgroundColor: 'var(--accent-ember)' }}>
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
|
||||||
{selectedCards.size}
|
style={{ backgroundColor: 'var(--accent-ember)' }}
|
||||||
|
>
|
||||||
|
{queue.selectedCards.size}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
|
{queue.selectedCards.size === 1 ? 'card selected' : 'cards selected'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBulkAction('owned')}
|
onClick={() => queue.handleBulkAction('owned')}
|
||||||
disabled={isProcessing}
|
disabled={queue.isProcessing}
|
||||||
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
|
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' }}
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||||||
>
|
>
|
||||||
|
|
@ -530,22 +270,22 @@ export default function Scanner() {
|
||||||
{VOCAB.ADD_TO_MY_COLLECTION}
|
{VOCAB.ADD_TO_MY_COLLECTION}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{collections.length > 0 && (
|
{queue.collections.length > 0 && (
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const collectionId = e.target.value;
|
const collectionId = e.target.value;
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
handleBulkAction('collection', collectionId);
|
queue.handleBulkAction('collection', collectionId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isProcessing}
|
disabled={queue.isProcessing}
|
||||||
className="px-4 py-2 rounded-lg font-medium"
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
||||||
aria-label="Add selected cards to list"
|
aria-label="Add selected cards to list"
|
||||||
>
|
>
|
||||||
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
|
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
|
||||||
{collections.map(collection => (
|
{queue.collections.map((collection) => (
|
||||||
<option key={collection.id} value={collection.id}>
|
<option key={collection.id} value={collection.id}>
|
||||||
{collection.name}
|
{collection.name}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -553,22 +293,22 @@ export default function Scanner() {
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{decks.length > 0 && (
|
{queue.decks.length > 0 && (
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const deckId = e.target.value;
|
const deckId = e.target.value;
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (deckId) {
|
if (deckId) {
|
||||||
handleBulkAction('deck', deckId);
|
queue.handleBulkAction('deck', deckId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isProcessing}
|
disabled={queue.isProcessing}
|
||||||
className="px-4 py-2 rounded-lg font-medium"
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
||||||
aria-label="Add selected cards to deck"
|
aria-label="Add selected cards to deck"
|
||||||
>
|
>
|
||||||
<option value="">🃏 Add to Deck</option>
|
<option value="">🃏 Add to Deck</option>
|
||||||
{decks.map(deck => (
|
{queue.decks.map((deck) => (
|
||||||
<option key={deck.id} value={deck.id}>
|
<option key={deck.id} value={deck.id}>
|
||||||
{deck.name} ({deck.game})
|
{deck.name} ({deck.game})
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -577,12 +317,10 @@ export default function Scanner() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
|
||||||
|
|
||||||
{/* Clear Selection */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCards(new Set())}
|
onClick={queue.clearSelection}
|
||||||
className="px-3 py-2 rounded-lg hover:opacity-80"
|
className="px-3 py-2 rounded-lg hover:opacity-80"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
aria-label="Clear selection"
|
aria-label="Clear selection"
|
||||||
|
|
@ -593,11 +331,7 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bulk Actions Modal */}
|
{queue.showCreateCollection && (
|
||||||
{/* This modal is no longer needed as bulk actions are in a floating toolbar */}
|
|
||||||
|
|
||||||
{/* Create List Modal */}
|
|
||||||
{showCreateCollection && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div
|
<div
|
||||||
ref={createCollectionDialogRef}
|
ref={createCollectionDialogRef}
|
||||||
|
|
@ -617,31 +351,31 @@ export default function Scanner() {
|
||||||
id="create-collection-name"
|
id="create-collection-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="List name..."
|
placeholder="List name..."
|
||||||
value={newCollectionName}
|
value={queue.newCollectionName}
|
||||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
onChange={(e) => queue.setNewCollectionName(e.target.value)}
|
||||||
className="w-full px-4 py-2 rounded-lg border mb-4"
|
className="w-full px-4 py-2 rounded-lg border mb-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
borderColor: 'var(--border)',
|
borderColor: 'var(--border)',
|
||||||
color: 'var(--text-primary)'
|
color: 'var(--text-primary)',
|
||||||
}}
|
}}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
createCollection();
|
queue.createCollection();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={createCollection}
|
onClick={queue.createCollection}
|
||||||
disabled={!newCollectionName.trim()}
|
disabled={!queue.newCollectionName.trim()}
|
||||||
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
|
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateCollection(false)}
|
onClick={() => queue.setShowCreateCollection(false)}
|
||||||
className="flex-1 px-4 py-2 rounded-lg border font-medium"
|
className="flex-1 px-4 py-2 rounded-lg border font-medium"
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
>
|
>
|
||||||
|
|
@ -652,10 +386,7 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OCR Settings Modal */}
|
{showOCRSettings && <OCRSettings onClose={() => setShowOCRSettings(false)} />}
|
||||||
{showOCRSettings && (
|
|
||||||
<OCRSettings onClose={() => setShowOCRSettings(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue