Compare commits
1 commit
main
...
feat/redes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a331f897d |
2 changed files with 350 additions and 39 deletions
214
components/ScannerDestinationPicker.js
Normal file
214
components/ScannerDestinationPicker.js
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
const GAME_OPTIONS = [
|
||||||
|
{ value: 'All', label: 'All games' },
|
||||||
|
{ value: 'MTG', label: 'Magic' },
|
||||||
|
{ value: 'Pokemon', label: 'Pokémon' },
|
||||||
|
{ value: 'Lorcana', label: 'Lorcana' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchesGameFilter(itemGame, filter) {
|
||||||
|
if (filter === 'All') return true;
|
||||||
|
const normalized = (itemGame || '').toLowerCase();
|
||||||
|
if (filter === 'MTG') return normalized === 'mtg' || normalized.includes('magic');
|
||||||
|
if (filter === 'Pokemon') return normalized.includes('pokemon') || normalized.includes('pokémon');
|
||||||
|
if (filter === 'Lorcana') return normalized.includes('lorcana');
|
||||||
|
return itemGame === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerDestinationPicker({
|
||||||
|
gameFilter,
|
||||||
|
onGameFilterChange,
|
||||||
|
destination,
|
||||||
|
onDestinationChange,
|
||||||
|
collections = [],
|
||||||
|
decks = [],
|
||||||
|
disabled = false,
|
||||||
|
}) {
|
||||||
|
const filteredCollections = collections.filter(
|
||||||
|
(collection) => matchesGameFilter(collection.tcg, gameFilter) || collection.tcg === 'All'
|
||||||
|
);
|
||||||
|
const filteredDecks = decks.filter((deck) => matchesGameFilter(deck.game, gameFilter));
|
||||||
|
|
||||||
|
const handleTypeChange = (type) => {
|
||||||
|
if (type === 'owned') {
|
||||||
|
onDestinationChange({ type: 'owned', id: null, label: 'My owned cards' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'collection' && filteredCollections.length > 0) {
|
||||||
|
const first = filteredCollections[0];
|
||||||
|
onDestinationChange({
|
||||||
|
type: 'collection',
|
||||||
|
id: first.id,
|
||||||
|
label: first.name,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'deck' && filteredDecks.length > 0) {
|
||||||
|
const first = filteredDecks[0];
|
||||||
|
onDestinationChange({
|
||||||
|
type: 'deck',
|
||||||
|
id: first.id,
|
||||||
|
label: first.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTargetChange = (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (!value || !destination) return;
|
||||||
|
|
||||||
|
if (destination.type === 'collection') {
|
||||||
|
const collection = filteredCollections.find((item) => String(item.id) === value);
|
||||||
|
if (collection) {
|
||||||
|
onDestinationChange({
|
||||||
|
type: 'collection',
|
||||||
|
id: collection.id,
|
||||||
|
label: collection.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.type === 'deck') {
|
||||||
|
const deck = filteredDecks.find((item) => String(item.id) === value);
|
||||||
|
if (deck) {
|
||||||
|
onDestinationChange({
|
||||||
|
type: 'deck',
|
||||||
|
id: deck.id,
|
||||||
|
label: deck.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="mx-6 mb-4 rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
aria-label="Scan destination"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Scan destination
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{destination
|
||||||
|
? `Every scan goes to: ${destination.label}`
|
||||||
|
: 'Choose where scanned cards should land'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Game filter</span>
|
||||||
|
<select
|
||||||
|
value={gameFilter}
|
||||||
|
onChange={(e) => onGameFilterChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-3 py-2 rounded-lg border min-w-[10rem]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GAME_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-wrap gap-2" disabled={disabled}>
|
||||||
|
<legend className="sr-only">Destination type</legend>
|
||||||
|
{[
|
||||||
|
{ type: 'owned', label: '💎 Owned' },
|
||||||
|
{ type: 'collection', label: '📚 Collection' },
|
||||||
|
{ type: 'deck', label: '🃏 Deck' },
|
||||||
|
].map((option) => {
|
||||||
|
const isActive = destination?.type === option.type;
|
||||||
|
const isDisabled =
|
||||||
|
(option.type === 'collection' && filteredCollections.length === 0) ||
|
||||||
|
(option.type === 'deck' && filteredDecks.length === 0);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTypeChange(option.type)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className="px-3 py-2 rounded-lg text-sm font-medium border disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||||
|
borderColor: isActive ? 'var(--accent-ember)' : 'var(--border)',
|
||||||
|
color: isActive ? 'white' : 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{destination?.type === 'collection' && filteredCollections.length > 0 && (
|
||||||
|
<label className="flex flex-col gap-1 mt-4 text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Collection</span>
|
||||||
|
<select
|
||||||
|
value={String(destination.id)}
|
||||||
|
onChange={handleTargetChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-3 py-2 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredCollections.map((collection) => (
|
||||||
|
<option key={collection.id} value={collection.id}>
|
||||||
|
{collection.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{destination?.type === 'deck' && filteredDecks.length > 0 && (
|
||||||
|
<label className="flex flex-col gap-1 mt-4 text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Deck</span>
|
||||||
|
<select
|
||||||
|
value={String(destination.id)}
|
||||||
|
onChange={handleTargetChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-3 py-2 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredDecks.map((deck) => (
|
||||||
|
<option key={deck.id} value={deck.id}>
|
||||||
|
{deck.name} ({deck.game})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{destination?.type === 'collection' && filteredCollections.length === 0 && (
|
||||||
|
<p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No collections match this game filter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{destination?.type === 'deck' && filteredDecks.length === 0 && (
|
||||||
|
<p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No decks match this game filter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
pages/scanner.js
169
pages/scanner.js
|
|
@ -2,11 +2,36 @@ import { useState, useEffect, useRef } 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';
|
||||||
|
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
||||||
import OCRSettings from '../components/OCRSettings';
|
import OCRSettings from '../components/OCRSettings';
|
||||||
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
|
||||||
|
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||||||
|
|
||||||
|
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
|
||||||
|
|
||||||
|
function loadSavedScannerSession() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
||||||
|
return {
|
||||||
|
destination: saved?.destination || DEFAULT_DESTINATION,
|
||||||
|
gameFilter: saved?.gameFilter || 'All',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -23,6 +48,11 @@ export default function Scanner() {
|
||||||
const [bulkTarget, setBulkTarget] = useState('');
|
const [bulkTarget, setBulkTarget] = useState('');
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const addingInFlightRef = useRef(new Set());
|
const addingInFlightRef = useRef(new Set());
|
||||||
|
const [sessionDestination, setSessionDestination] = useState(
|
||||||
|
() => loadSavedScannerSession().destination
|
||||||
|
);
|
||||||
|
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
||||||
|
const [autoRouteError, setAutoRouteError] = useState(null);
|
||||||
|
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
@ -42,6 +72,38 @@ export default function Scanner() {
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(
|
||||||
|
SESSION_STORAGE_KEY,
|
||||||
|
JSON.stringify({ destination: sessionDestination, gameFilter })
|
||||||
|
);
|
||||||
|
}, [sessionDestination, gameFilter]);
|
||||||
|
|
||||||
|
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 () => {
|
const loadCollections = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/collections', {
|
const response = await fetch('/api/collections', {
|
||||||
|
|
@ -77,44 +139,55 @@ export default function Scanner() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardScanned = async (cardData) => {
|
const handleCardScanned = async (cardData) => {
|
||||||
console.log('Card scanned:', cardData);
|
setAutoRouteError(null);
|
||||||
console.log('Looking for existing card with name:', cardData.name, 'set:', cardData.set);
|
|
||||||
|
|
||||||
// Check if this card already exists in the queue
|
const existingCardIndex = scannedCards.findIndex((existing) =>
|
||||||
setScannedCards(prev => {
|
existing.name === cardData.name &&
|
||||||
console.log('Current queue:', prev.map(c => ({ name: c.name, set: c.set, processed: c.processed })));
|
existing.set === cardData.set &&
|
||||||
|
!existing.processed
|
||||||
|
);
|
||||||
|
|
||||||
const existingCardIndex = prev.findIndex(existing =>
|
let cardEntry;
|
||||||
existing.name === cardData.name &&
|
|
||||||
existing.set === cardData.set &&
|
|
||||||
!existing.processed
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingCardIndex !== -1) {
|
if (existingCardIndex !== -1) {
|
||||||
console.log(`📈 Incrementing quantity for existing card: ${cardData.name}`);
|
const existing = scannedCards[existingCardIndex];
|
||||||
// Increment quantity of existing card
|
cardEntry = {
|
||||||
const updatedCards = [...prev];
|
...existing,
|
||||||
updatedCards[existingCardIndex] = {
|
quantity: (existing.quantity || 1) + 1,
|
||||||
...updatedCards[existingCardIndex],
|
timestamp: new Date().toISOString(),
|
||||||
quantity: (updatedCards[existingCardIndex].quantity || 1) + 1,
|
};
|
||||||
timestamp: new Date().toISOString() // Update timestamp
|
setScannedCards((prev) => {
|
||||||
};
|
const updated = [...prev];
|
||||||
return updatedCards;
|
updated[existingCardIndex] = cardEntry;
|
||||||
} else {
|
return updated;
|
||||||
console.log(`🆕 Adding new card to queue: ${cardData.name}`);
|
});
|
||||||
// Add new card to queue
|
} else {
|
||||||
const scannedCard = {
|
cardEntry = {
|
||||||
...cardData,
|
...cardData,
|
||||||
id: Date.now() + Math.random(), // More unique ID for the queue
|
id: Date.now() + Math.random(),
|
||||||
name: cardData.name,
|
name: cardData.name,
|
||||||
set: cardData.set,
|
set: cardData.set,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
processed: false
|
processed: false,
|
||||||
};
|
};
|
||||||
return [scannedCard, ...prev];
|
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 {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quantity management functions
|
// Quantity management functions
|
||||||
|
|
@ -361,10 +434,34 @@ export default function Scanner() {
|
||||||
🃏 Card Scanner
|
🃏 Card Scanner
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Scan cards to identify them, then choose what to do with your collection
|
Pick a destination once — every scan lands there until you change it
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content - Full Height */}
|
{/* 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 */}
|
{/* Camera Scanner */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue