refactor(scanner): extract session and route API libs (page Brief 1) (#74)
Move scanner session persistence, queue merge helpers, and destination routing fetch calls into lib/scanner-session.js and lib/scanner-route-api.js. Load collections/decks on mount (were defined but never invoked). Remove unused mana-symbol imports and dead select-all helpers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cf770b618c
commit
83d73eecaf
5 changed files with 351 additions and 222 deletions
113
lib/scanner-route-api.js
Normal file
113
lib/scanner-route-api.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { VOCAB } from './collection-vocabulary.js';
|
||||||
|
|
||||||
|
function authHeaders(json = true) {
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
};
|
||||||
|
if (json) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScannerCardPayload(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScannerCollections() {
|
||||||
|
const response = await fetch('/api/collections', { headers: authHeaders(false) });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load lists');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.filter((collection) => !collection.is_system_collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScannerDecks() {
|
||||||
|
const response = await fetch('/api/decks', { headers: authHeaders(false) });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load decks');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addScannedCardToOwned(cardData) {
|
||||||
|
const response = await fetch('/api/user-cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify(buildScannerCardPayload(cardData)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addScannedCardToCollection(cardData, collectionId) {
|
||||||
|
const response = await fetch(`/api/collections/${collectionId}/cards`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify(buildScannerCardPayload(cardData)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to add to ${VOCAB.LIST}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addScannedCardToDeck(cardData, deckId) {
|
||||||
|
const response = await fetch(`/api/decks/${deckId}/cards`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify(buildScannerCardPayload(cardData)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to add to deck');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createScannerCollection(name) {
|
||||||
|
const response = await fetch('/api/collections', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
description: 'Created from card scanner',
|
||||||
|
is_public: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function routeScannedCardToDestination(cardData, destination) {
|
||||||
|
if (!destination || !cardData.databaseId) return false;
|
||||||
|
|
||||||
|
if (destination.type === 'owned') {
|
||||||
|
await addScannedCardToOwned(cardData);
|
||||||
|
} else if (destination.type === 'collection') {
|
||||||
|
await addScannedCardToCollection(cardData, destination.id);
|
||||||
|
} else if (destination.type === 'deck') {
|
||||||
|
await addScannedCardToDeck(cardData, destination.id);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
87
lib/scanner-session.js
Normal file
87
lib/scanner-session.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { VOCAB } from './collection-vocabulary.js';
|
||||||
|
|
||||||
|
export const SCANNER_SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||||||
|
|
||||||
|
export const DEFAULT_SCANNER_DESTINATION = {
|
||||||
|
type: 'owned',
|
||||||
|
id: null,
|
||||||
|
label: VOCAB.MY_COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
||||||
|
|
||||||
|
export function loadSavedScannerSession() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
destination: DEFAULT_SCANNER_DESTINATION,
|
||||||
|
gameFilter: 'All',
|
||||||
|
scanDefaults: DEFAULT_SCAN_DEFAULTS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(SCANNER_SESSION_STORAGE_KEY));
|
||||||
|
return {
|
||||||
|
destination: saved?.destination || DEFAULT_SCANNER_DESTINATION,
|
||||||
|
gameFilter: saved?.gameFilter || 'All',
|
||||||
|
scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
destination: DEFAULT_SCANNER_DESTINATION,
|
||||||
|
gameFilter: 'All',
|
||||||
|
scanDefaults: DEFAULT_SCAN_DEFAULTS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveScannerSession({ destination, gameFilter, scanDefaults }) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(
|
||||||
|
SCANNER_SESSION_STORAGE_KEY,
|
||||||
|
JSON.stringify({ destination, gameFilter, scanDefaults })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScanCardId() {
|
||||||
|
return Date.now() + Math.random();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destinationActionKey(destination) {
|
||||||
|
if (!destination) return 'pending';
|
||||||
|
return destination.type === 'owned' ? 'owned' : destination.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeScannedCardEntry(existingCards, cardData, scanDefaults) {
|
||||||
|
const existingCardIndex = existingCards.findIndex(
|
||||||
|
(existing) =>
|
||||||
|
existing.name === cardData.name && existing.set === cardData.set && !existing.processed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCardIndex !== -1) {
|
||||||
|
const existing = existingCards[existingCardIndex];
|
||||||
|
const cardEntry = {
|
||||||
|
...existing,
|
||||||
|
quantity: (existing.quantity || 1) + 1,
|
||||||
|
scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const updated = [...existingCards];
|
||||||
|
updated[existingCardIndex] = cardEntry;
|
||||||
|
return { cardEntry, scannedCards: updated, merged: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardEntry = {
|
||||||
|
...cardData,
|
||||||
|
id: createScanCardId(),
|
||||||
|
name: cardData.name,
|
||||||
|
set: cardData.set,
|
||||||
|
quantity: 1,
|
||||||
|
condition: scanDefaults.condition,
|
||||||
|
isFoil: scanDefaults.isFoil,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
processed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { cardEntry, scannedCards: [cardEntry, ...existingCards], merged: false };
|
||||||
|
}
|
||||||
281
pages/scanner.js
281
pages/scanner.js
|
|
@ -5,41 +5,25 @@ import CameraScanner from '../components/CameraScanner';
|
||||||
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
||||||
import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem';
|
import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem';
|
||||||
import OCRSettings from '../components/OCRSettings';
|
import OCRSettings from '../components/OCRSettings';
|
||||||
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
|
||||||
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 {
|
||||||
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
addScannedCardToCollection,
|
||||||
|
addScannedCardToDeck,
|
||||||
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
|
addScannedCardToOwned,
|
||||||
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
createScannerCollection,
|
||||||
|
fetchScannerCollections,
|
||||||
function loadSavedScannerSession() {
|
fetchScannerDecks,
|
||||||
if (typeof window === 'undefined') {
|
routeScannedCardToDestination,
|
||||||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
} from '../lib/scanner-route-api.js';
|
||||||
}
|
import {
|
||||||
try {
|
DEFAULT_SCANNER_DESTINATION,
|
||||||
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
destinationActionKey,
|
||||||
return {
|
loadSavedScannerSession,
|
||||||
destination: saved?.destination || DEFAULT_DESTINATION,
|
mergeScannedCardEntry,
|
||||||
gameFilter: saved?.gameFilter || 'All',
|
saveScannerSession,
|
||||||
scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS,
|
} from '../lib/scanner-session.js';
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createScanCardId() {
|
|
||||||
return Date.now() + Math.random();
|
|
||||||
}
|
|
||||||
|
|
||||||
function destinationActionKey(destination) {
|
|
||||||
if (!destination) return 'pending';
|
|
||||||
return destination.type === 'owned' ? 'owned' : destination.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Scanner() {
|
export default function Scanner() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
@ -66,9 +50,6 @@ export default function Scanner() {
|
||||||
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
||||||
const [autoRouteError, setAutoRouteError] = useState(null);
|
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)
|
// Redirect to login if not authenticated (wait for verify to finish)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
|
|
@ -77,109 +58,50 @@ export default function Scanner() {
|
||||||
}, [authLoading, user, router]);
|
}, [authLoading, user, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults });
|
||||||
localStorage.setItem(
|
|
||||||
SESSION_STORAGE_KEY,
|
|
||||||
JSON.stringify({ 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) => {
|
||||||
if (!current || current.type === 'owned') return current;
|
if (!current || current.type === 'owned') return current;
|
||||||
return DEFAULT_DESTINATION;
|
return DEFAULT_SCANNER_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) => {
|
const handleCardScanned = async (cardData) => {
|
||||||
setAutoRouteError(null);
|
setAutoRouteError(null);
|
||||||
|
|
||||||
const existingCardIndex = scannedCards.findIndex((existing) =>
|
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
||||||
existing.name === cardData.name &&
|
scannedCards,
|
||||||
existing.set === cardData.set &&
|
cardData,
|
||||||
!existing.processed
|
scanDefaults
|
||||||
);
|
);
|
||||||
|
setScannedCards(nextQueue);
|
||||||
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: createScanCardId(),
|
|
||||||
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 (!sessionDestination) return;
|
||||||
|
|
||||||
|
|
@ -190,7 +112,7 @@ export default function Scanner() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!tryBeginAdding(cardEntry.id)) return;
|
if (!tryBeginAdding(cardEntry.id)) return;
|
||||||
await routeCardToDestination(cardEntry, sessionDestination);
|
await routeScannedCardToDestination(cardEntry, sessionDestination);
|
||||||
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
|
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auto-route failed:', error);
|
console.error('Auto-route failed:', error);
|
||||||
|
|
@ -242,7 +164,7 @@ export default function Scanner() {
|
||||||
const addSingleCardToOwned = async (card) => {
|
const addSingleCardToOwned = async (card) => {
|
||||||
if (!tryBeginAdding(card.id)) return;
|
if (!tryBeginAdding(card.id)) return;
|
||||||
try {
|
try {
|
||||||
await addToOwnedCards(card);
|
await addScannedCardToOwned(card);
|
||||||
markCardAsProcessed(card.id, 'owned');
|
markCardAsProcessed(card.id, 'owned');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding card to owned:', error);
|
console.error('Error adding card to owned:', error);
|
||||||
|
|
@ -254,7 +176,7 @@ export default function Scanner() {
|
||||||
const addSingleCardToCollection = async (card, collectionId) => {
|
const addSingleCardToCollection = async (card, collectionId) => {
|
||||||
if (!tryBeginAdding(card.id)) return;
|
if (!tryBeginAdding(card.id)) return;
|
||||||
try {
|
try {
|
||||||
await addToCollection(card, collectionId);
|
await addScannedCardToCollection(card, collectionId);
|
||||||
markCardAsProcessed(card.id, 'collection');
|
markCardAsProcessed(card.id, 'collection');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding card to collection:', error);
|
console.error('Error adding card to collection:', error);
|
||||||
|
|
@ -266,7 +188,7 @@ export default function Scanner() {
|
||||||
const addSingleCardToDeck = async (card, deckId) => {
|
const addSingleCardToDeck = async (card, deckId) => {
|
||||||
if (!tryBeginAdding(card.id)) return;
|
if (!tryBeginAdding(card.id)) return;
|
||||||
try {
|
try {
|
||||||
await addToDeck(card, deckId);
|
await addScannedCardToDeck(card, deckId);
|
||||||
markCardAsProcessed(card.id, 'deck');
|
markCardAsProcessed(card.id, 'deck');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding card to deck:', error);
|
console.error('Error adding card to deck:', error);
|
||||||
|
|
@ -289,21 +211,21 @@ export default function Scanner() {
|
||||||
if (action === 'owned') {
|
if (action === 'owned') {
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addToOwnedCards(card);
|
await addScannedCardToOwned(card);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
} else if (action === 'collection' && target) {
|
} else if (action === 'collection' && target) {
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addToCollection(card, target);
|
await addScannedCardToCollection(card, target);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
} else if (action === 'deck' && target) {
|
} else if (action === 'deck' && target) {
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addToDeck(card, target);
|
await addScannedCardToDeck(card, target);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
|
|
@ -337,91 +259,15 @@ export default function Scanner() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 ${VOCAB.LIST}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 () => {
|
const createCollection = async () => {
|
||||||
if (!newCollectionName.trim()) return;
|
if (!newCollectionName.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/collections', {
|
const newCollection = await createScannerCollection(newCollectionName.trim());
|
||||||
method: 'POST',
|
setCollections((prev) => [newCollection, ...prev]);
|
||||||
headers: {
|
setBulkTarget(newCollection.id.toString());
|
||||||
'Content-Type': 'application/json',
|
setNewCollectionName('');
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
setShowCreateCollection(false);
|
||||||
},
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error creating collection:', error);
|
console.error('Error creating collection:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -453,15 +299,6 @@ export default function Scanner() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAllCards = () => {
|
|
||||||
const unprocessedCards = scannedCards.filter(card => !card.processed);
|
|
||||||
setSelectedCards(new Set(unprocessedCards.map(card => card.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deselectAllCards = () => {
|
|
||||||
setSelectedCards(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout user={null}>
|
<Layout user={null}>
|
||||||
|
|
|
||||||
31
test/lib/scanner-route-api.test.js
Normal file
31
test/lib/scanner-route-api.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildScannerCardPayload } from '../../lib/scanner-route-api.js';
|
||||||
|
|
||||||
|
describe('buildScannerCardPayload', () => {
|
||||||
|
it('maps scanner queue fields to API body shape', () => {
|
||||||
|
expect(
|
||||||
|
buildScannerCardPayload({
|
||||||
|
databaseId: 42,
|
||||||
|
quantity: 2,
|
||||||
|
condition: 'LP',
|
||||||
|
isFoil: true,
|
||||||
|
scanImageUrl: 'https://blob.example/scan.jpg',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
cardId: 42,
|
||||||
|
quantity: 2,
|
||||||
|
condition: 'LP',
|
||||||
|
is_foil: true,
|
||||||
|
scan_image_url: 'https://blob.example/scan.jpg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits scan_image_url when capture was not uploaded', () => {
|
||||||
|
expect(buildScannerCardPayload({ databaseId: 1 })).toEqual({
|
||||||
|
cardId: 1,
|
||||||
|
quantity: 1,
|
||||||
|
condition: 'NM',
|
||||||
|
is_foil: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
test/lib/scanner-session.test.js
Normal file
61
test/lib/scanner-session.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_SCANNER_DESTINATION,
|
||||||
|
destinationActionKey,
|
||||||
|
mergeScannedCardEntry,
|
||||||
|
} from '../../lib/scanner-session.js';
|
||||||
|
|
||||||
|
describe('destinationActionKey', () => {
|
||||||
|
it('maps owned destination to owned action key', () => {
|
||||||
|
expect(destinationActionKey(DEFAULT_SCANNER_DESTINATION)).toBe('owned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending when destination is missing', () => {
|
||||||
|
expect(destinationActionKey(null)).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeScannedCardEntry', () => {
|
||||||
|
const scanDefaults = { condition: 'NM', isFoil: false };
|
||||||
|
|
||||||
|
it('creates a new queue entry with scan defaults', () => {
|
||||||
|
const { cardEntry, scannedCards, merged } = mergeScannedCardEntry([], {
|
||||||
|
name: 'Pikachu',
|
||||||
|
set: 'Base',
|
||||||
|
databaseId: 1,
|
||||||
|
}, scanDefaults);
|
||||||
|
|
||||||
|
expect(merged).toBe(false);
|
||||||
|
expect(scannedCards).toHaveLength(1);
|
||||||
|
expect(cardEntry).toMatchObject({
|
||||||
|
name: 'Pikachu',
|
||||||
|
set: 'Base',
|
||||||
|
quantity: 1,
|
||||||
|
condition: 'NM',
|
||||||
|
isFoil: false,
|
||||||
|
processed: false,
|
||||||
|
});
|
||||||
|
expect(cardEntry.id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments quantity for an unprocessed duplicate name+set', () => {
|
||||||
|
const existing = {
|
||||||
|
id: 99,
|
||||||
|
name: 'Bolt',
|
||||||
|
set: 'Alpha',
|
||||||
|
quantity: 2,
|
||||||
|
processed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { cardEntry, scannedCards, merged } = mergeScannedCardEntry(
|
||||||
|
[existing],
|
||||||
|
{ name: 'Bolt', set: 'Alpha', databaseId: 1, scanImageUrl: 'https://blob/new.jpg' },
|
||||||
|
scanDefaults
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged).toBe(true);
|
||||||
|
expect(scannedCards).toHaveLength(1);
|
||||||
|
expect(cardEntry.quantity).toBe(3);
|
||||||
|
expect(cardEntry.scanImageUrl).toBe('https://blob/new.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue