refactor(scanner): extract session and route API libs (page Brief 1) #74

Merged
varutasu merged 1 commit from refactor/scanner-page-split-brief-1 into main 2026-06-02 17:15:27 -04:00
5 changed files with 351 additions and 222 deletions

113
lib/scanner-route-api.js Normal file
View 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
View 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 };
}

View file

@ -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: {
'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()); setBulkTarget(newCollection.id.toString());
setNewCollectionName(''); setNewCollectionName('');
setShowCreateCollection(false); 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}>

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

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