feat(scanner): condition, foil, quantity, and ownership badge (Brief 2) (#43)
Extract ScannedCardItem with per-card metadata controls and ownership lookup via GET /api/cards/[id]/ownership. Propagate condition, foil, and quantity through owned/collection/deck POST paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
47a1abbe4d
commit
24c9da4095
5 changed files with 422 additions and 237 deletions
293
components/ScannedCardItem.js
Normal file
293
components/ScannedCardItem.js
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
||||||
|
|
||||||
|
export default function ScannedCardItem({
|
||||||
|
card,
|
||||||
|
collections,
|
||||||
|
decks,
|
||||||
|
selected,
|
||||||
|
onToggleSelect,
|
||||||
|
onIncrement,
|
||||||
|
onDecrement,
|
||||||
|
onUpdateMetadata,
|
||||||
|
onMarkOwned,
|
||||||
|
onAddToCollection,
|
||||||
|
onAddToDeck,
|
||||||
|
onRemove,
|
||||||
|
}) {
|
||||||
|
const [ownedQuantity, setOwnedQuantity] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!card.databaseId) {
|
||||||
|
setOwnedQuantity(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cards/${card.databaseId}/ownership`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setOwnedQuantity(typeof data.quantity === 'number' ? data.quantity : 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setOwnedQuantity(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [card.databaseId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderColor: selected ? 'var(--accent-ember)' : 'var(--border)',
|
||||||
|
borderWidth: selected ? '2px' : '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-20 h-28 rounded-lg overflow-hidden flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
|
{card.image_url ? (
|
||||||
|
<img src={card.image_url} alt={card.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-xs p-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<div className="text-2xl mb-1">🃏</div>
|
||||||
|
<div>No Image</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!card.processed && (
|
||||||
|
<div className="absolute top-1 left-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggleSelect}
|
||||||
|
className="w-5 h-5 rounded border-2 border-white shadow-lg"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
aria-label={`Select ${card.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{card.confidence && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
card.confidence >= 90
|
||||||
|
? 'var(--accent-gold)'
|
||||||
|
: card.confidence >= 70
|
||||||
|
? 'var(--accent-ember)'
|
||||||
|
: 'var(--text-secondary)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(card.confidence)}% confidence
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-2 gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{card.name}
|
||||||
|
</h3>
|
||||||
|
{card.isExisting && (
|
||||||
|
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
|
||||||
|
✅ Found in database
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ownedQuantity !== null && ownedQuantity > 0 && (
|
||||||
|
<div
|
||||||
|
className="text-xs font-medium mt-1"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-label={`You already own ${ownedQuantity} copies of this card`}
|
||||||
|
>
|
||||||
|
You own {ownedQuantity}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!card.processed && (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrement}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{card.quantity || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrement}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!card.processed && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span>Condition</span>
|
||||||
|
<select
|
||||||
|
value={card.condition || 'NM'}
|
||||||
|
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
|
||||||
|
className="px-2 py-1 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CONDITION_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(card.isFoil)}
|
||||||
|
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
Foil
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
{card.set && (
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span className="font-semibold">Set:</span> {card.set}
|
||||||
|
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.cardNumber && (
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span className="font-semibold">Number:</span> {card.cardNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!card.processed && (card.condition || card.isFoil) && (
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span className="font-semibold">Adding as:</span>{' '}
|
||||||
|
{card.condition || 'NM'}
|
||||||
|
{card.isFoil ? ' · Foil' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!card.processed ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMarkOwned}
|
||||||
|
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
|
||||||
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||||||
|
>
|
||||||
|
💎 Mark Owned
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="px-3 py-2 rounded text-sm hover:opacity-80"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
e.target.value = '';
|
||||||
|
if (value) onAddToCollection(value);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 rounded text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
>
|
||||||
|
<option value="">📚 Add to Collection</option>
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<option key={collection.id} value={collection.id}>
|
||||||
|
{collection.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{decks.length > 0 && (
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
e.target.value = '';
|
||||||
|
if (value) onAddToDeck(value);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 rounded text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
>
|
||||||
|
<option value="">🃏 Add to Deck</option>
|
||||||
|
{decks.map((deck) => (
|
||||||
|
<option key={deck.id} value={deck.id}>
|
||||||
|
{deck.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
|
||||||
|
<span aria-hidden="true">✅</span>
|
||||||
|
<span>Added to {card.processedAction}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,29 +2,45 @@ import { sql } from '@vercel/postgres';
|
||||||
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../../lib/permission-middleware';
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'POST') {
|
|
||||||
return res.status(405).json({ error: 'Method not allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get authenticated user
|
|
||||||
const user = await getUserFromRequest(req);
|
const user = await getUserFromRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query;
|
const { id } = req.query;
|
||||||
const { quantity } = req.body;
|
const cardId = parseInt(id, 10);
|
||||||
|
|
||||||
if (!id || quantity === undefined) {
|
if (!id || Number.isNaN(cardId)) {
|
||||||
return res.status(400).json({ error: 'Card ID and quantity are required' });
|
return res.status(400).json({ error: 'Valid card ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardId = parseInt(id);
|
if (req.method === 'GET') {
|
||||||
const cardQuantity = parseInt(quantity);
|
const result = await sql`
|
||||||
|
SELECT COALESCE(SUM(quantity), 0) AS quantity
|
||||||
|
FROM user_cards
|
||||||
|
WHERE user_id = ${user.userId} AND card_id = ${cardId}
|
||||||
|
`;
|
||||||
|
|
||||||
if (isNaN(cardId) || isNaN(cardQuantity) || cardQuantity < 0) {
|
return res.status(200).json({
|
||||||
return res.status(400).json({ error: 'Invalid card ID or quantity' });
|
quantity: parseInt(result.rows[0]?.quantity, 10) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { quantity } = req.body;
|
||||||
|
|
||||||
|
if (quantity === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Quantity is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardQuantity = parseInt(quantity, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(cardQuantity) || cardQuantity < 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid quantity' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the card exists
|
// Verify the card exists
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export default async function handler(req, res) {
|
||||||
return res.status(403).json({ error: 'You do not have permission to add cards to this collection' });
|
return res.status(403).json({ error: 'You do not have permission to add cards to this collection' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cardId, quantity = 1 } = req.body;
|
const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body;
|
||||||
|
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
return res.status(400).json({ error: 'Card ID is required' });
|
return res.status(400).json({ error: 'Card ID is required' });
|
||||||
|
|
@ -134,6 +134,8 @@ export default async function handler(req, res) {
|
||||||
cardName: cardRecord.name,
|
cardName: cardRecord.name,
|
||||||
quantityAdded: quantity,
|
quantityAdded: quantity,
|
||||||
newQuantity: result.rows[0]?.quantity,
|
newQuantity: result.rows[0]?.quantity,
|
||||||
|
condition,
|
||||||
|
is_foil: Boolean(is_foil),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
@ -152,6 +154,8 @@ export default async function handler(req, res) {
|
||||||
cardId,
|
cardId,
|
||||||
cardName: cardRecord.name,
|
cardName: cardRecord.name,
|
||||||
quantityAdded: quantity,
|
quantityAdded: quantity,
|
||||||
|
condition,
|
||||||
|
is_foil: Boolean(is_foil),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,17 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const { cardId, quantity = 1 } = req.body;
|
const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body;
|
||||||
|
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
return res.status(400).json({ error: 'Card ID is required' });
|
return res.status(400).json({ error: 'Card ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedQuantity = parseInt(quantity, 10);
|
||||||
|
if (Number.isNaN(parsedQuantity) || parsedQuantity < 1) {
|
||||||
|
return res.status(400).json({ error: 'Quantity must be at least 1' });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if card already exists in deck
|
// Check if card already exists in deck
|
||||||
const existingResult = await sql`
|
const existingResult = await sql`
|
||||||
SELECT * FROM deck_cards
|
SELECT * FROM deck_cards
|
||||||
|
|
@ -34,7 +39,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (existingResult.rows.length > 0) {
|
if (existingResult.rows.length > 0) {
|
||||||
// Update quantity
|
// Update quantity
|
||||||
const newQuantity = existingResult.rows[0].quantity + quantity;
|
const newQuantity = existingResult.rows[0].quantity + parsedQuantity;
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE deck_cards
|
UPDATE deck_cards
|
||||||
SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP
|
SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
|
@ -44,11 +49,14 @@ export default async function handler(req, res) {
|
||||||
// Insert new record
|
// Insert new record
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO deck_cards (deck_id, card_id, quantity)
|
INSERT INTO deck_cards (deck_id, card_id, quantity)
|
||||||
VALUES (${deckId}, ${cardId}, ${quantity})
|
VALUES (${deckId}, ${cardId}, ${parsedQuantity})
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ message: 'Card added to deck' });
|
return res.status(200).json({
|
||||||
|
message: 'Card added to deck',
|
||||||
|
metadata: { condition, is_foil: Boolean(is_foil) },
|
||||||
|
});
|
||||||
|
|
||||||
} else if (req.method === 'GET') {
|
} else if (req.method === 'GET') {
|
||||||
// Get cards in deck
|
// Get cards in deck
|
||||||
|
|
|
||||||
304
pages/scanner.js
304
pages/scanner.js
|
|
@ -3,6 +3,7 @@ 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 ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
||||||
|
import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem';
|
||||||
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';
|
||||||
|
|
@ -11,19 +12,21 @@ import { useAuth } from '../lib/use-auth';
|
||||||
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||||||
|
|
||||||
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
|
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
|
||||||
|
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
||||||
|
|
||||||
function loadSavedScannerSession() {
|
function loadSavedScannerSession() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
||||||
return {
|
return {
|
||||||
destination: saved?.destination || DEFAULT_DESTINATION,
|
destination: saved?.destination || DEFAULT_DESTINATION,
|
||||||
gameFilter: saved?.gameFilter || 'All',
|
gameFilter: saved?.gameFilter || 'All',
|
||||||
|
scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +55,7 @@ export default function Scanner() {
|
||||||
() => loadSavedScannerSession().destination
|
() => loadSavedScannerSession().destination
|
||||||
);
|
);
|
||||||
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
||||||
|
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
||||||
const [autoRouteError, setAutoRouteError] = useState(null);
|
const [autoRouteError, setAutoRouteError] = useState(null);
|
||||||
|
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
|
|
@ -76,9 +80,9 @@ export default function Scanner() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
SESSION_STORAGE_KEY,
|
SESSION_STORAGE_KEY,
|
||||||
JSON.stringify({ destination: sessionDestination, gameFilter })
|
JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults })
|
||||||
);
|
);
|
||||||
}, [sessionDestination, gameFilter]);
|
}, [sessionDestination, gameFilter, scanDefaults]);
|
||||||
|
|
||||||
const handleGameFilterChange = (nextFilter) => {
|
const handleGameFilterChange = (nextFilter) => {
|
||||||
setGameFilter(nextFilter);
|
setGameFilter(nextFilter);
|
||||||
|
|
@ -168,6 +172,8 @@ export default function Scanner() {
|
||||||
name: cardData.name,
|
name: cardData.name,
|
||||||
set: cardData.set,
|
set: cardData.set,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
condition: scanDefaults.condition,
|
||||||
|
isFoil: scanDefaults.isFoil,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
processed: false,
|
processed: false,
|
||||||
};
|
};
|
||||||
|
|
@ -291,19 +297,28 @@ export default function Scanner() {
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCardMetadata = (cardId, patch) => {
|
||||||
|
setScannedCards((prev) =>
|
||||||
|
prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCardPayload = (cardData) => ({
|
||||||
|
cardId: cardData.databaseId,
|
||||||
|
quantity: cardData.quantity || 1,
|
||||||
|
condition: cardData.condition || 'NM',
|
||||||
|
is_foil: Boolean(cardData.isFoil),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper functions for API calls
|
// Helper functions for API calls
|
||||||
const addToOwnedCards = async (cardData) => {
|
const addToOwnedCards = async (cardData) => {
|
||||||
const response = await fetch('/api/user-cards', {
|
const response = await fetch('/api/user-cards', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildCardPayload(cardData)),
|
||||||
cardId: cardData.databaseId,
|
|
||||||
quantity: 1,
|
|
||||||
condition: 'NM'
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -318,10 +333,7 @@ export default function Scanner() {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildCardPayload(cardData)),
|
||||||
cardId: cardData.databaseId,
|
|
||||||
quantity: 1
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -334,12 +346,9 @@ export default function Scanner() {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildCardPayload(cardData)),
|
||||||
cardId: cardData.databaseId,
|
|
||||||
quantity: 1
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -462,6 +471,48 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mx-6 mb-4 rounded-xl border p-4 flex flex-wrap items-end gap-4"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Defaults for new scans
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Applied to each card when it enters the queue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Condition</span>
|
||||||
|
<select
|
||||||
|
value={scanDefaults.condition}
|
||||||
|
onChange={(e) => setScanDefaults((current) => ({ ...current, condition: e.target.value }))}
|
||||||
|
className="px-3 py-2 rounded-lg border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CONDITION_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm pb-2 cursor-pointer" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scanDefaults.isFoil}
|
||||||
|
onChange={(e) => setScanDefaults((current) => ({ ...current, isFoil: e.target.checked }))}
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
/>
|
||||||
|
Foil
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Full Height */}
|
{/* 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 */}
|
||||||
|
|
@ -529,208 +580,21 @@ export default function Scanner() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
scannedCards.map((card) => (
|
scannedCards.map((card) => (
|
||||||
<div
|
<ScannedCardItem
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
|
card={card}
|
||||||
style={{
|
collections={collections}
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
decks={decks}
|
||||||
borderColor: selectedCards.has(card.id) ? 'var(--accent-ember)' : 'var(--border)',
|
selected={selectedCards.has(card.id)}
|
||||||
borderWidth: selectedCards.has(card.id) ? '2px' : '1px'
|
onToggleSelect={() => toggleCardSelection(card.id)}
|
||||||
}}
|
onIncrement={() => incrementCardQuantity(card.id)}
|
||||||
>
|
onDecrement={() => decrementCardQuantity(card.id)}
|
||||||
{/* Card Thumbnail with Checkbox Overlay */}
|
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
||||||
<div className="relative flex-shrink-0">
|
onMarkOwned={() => addSingleCardToOwned(card)}
|
||||||
<div className="w-20 h-28 rounded-lg overflow-hidden bg-gray-200 flex items-center justify-center">
|
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
||||||
{card.image_url ? (
|
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
||||||
<img
|
onRemove={() => removeScannedCard(card.id)}
|
||||||
src={card.image_url}
|
/>
|
||||||
alt={card.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-xs text-gray-500 p-2">
|
|
||||||
<div className="text-2xl mb-1">🃏</div>
|
|
||||||
<div>No Image</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Checkbox Overlay */}
|
|
||||||
{!card.processed && (
|
|
||||||
<div className="absolute top-1 left-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedCards.has(card.id)}
|
|
||||||
onChange={() => toggleCardSelection(card.id)}
|
|
||||||
className="w-5 h-5 rounded border-2 border-white shadow-lg"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Confidence Badge */}
|
|
||||||
{card.confidence && (
|
|
||||||
<div className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: card.confidence >= 90 ? 'var(--accent-gold)' :
|
|
||||||
card.confidence >= 70 ? 'var(--accent-ember)' : 'var(--text-secondary)',
|
|
||||||
color: 'white'
|
|
||||||
}}>
|
|
||||||
{Math.round(card.confidence)}% confidence
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Title and Quantity Row */}
|
|
||||||
<div className="flex items-start justify-between mb-2 gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{card.name}
|
|
||||||
</h3>
|
|
||||||
{/* Database Status */}
|
|
||||||
{card.isExisting && (
|
|
||||||
<div className="text-xs text-green-600 font-medium">
|
|
||||||
✅ Found in database
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quantity Controls */}
|
|
||||||
{!card.processed && (
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => decrementCardQuantity(card.id)}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
|
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{card.quantity || 1}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => incrementCardQuantity(card.id)}
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
|
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Details */}
|
|
||||||
<div className="space-y-1 mb-3">
|
|
||||||
{card.set && (
|
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">Set:</span> {card.set}
|
|
||||||
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.cardNumber && (
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">Number:</span> {card.cardNumber}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.cardType && (
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">Type:</span> {card.cardType}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.rarity && (
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">Rarity:</span> {card.rarity}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.hp && (
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">HP:</span> {card.hp}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.manaCost && (
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="font-semibold">Mana Cost:</span> {card.manaCost}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{card.ocrText && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Scanned Text:
|
|
||||||
</div>
|
|
||||||
<div className="text-xs p-2 rounded max-h-16 overflow-y-auto"
|
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
|
|
||||||
{card.ocrText.substring(0, 150)}{card.ocrText.length > 150 ? '...' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!card.processed ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Primary Actions Row */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => addSingleCardToOwned(card)}
|
|
||||||
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
|
|
||||||
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
|
||||||
>
|
|
||||||
💎 Mark Owned
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => removeScannedCard(card.id)}
|
|
||||||
className="px-3 py-2 rounded text-sm hover:opacity-80"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary Actions Row */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{collections.length > 0 && (
|
|
||||||
<select
|
|
||||||
onChange={(e) => e.target.value && addSingleCardToCollection(card, e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 rounded text-sm"
|
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<option value="">📚 Add to Collection</option>
|
|
||||||
{collections.map(collection => (
|
|
||||||
<option key={collection.id} value={collection.id}>
|
|
||||||
{collection.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{decks.length > 0 && (
|
|
||||||
<select
|
|
||||||
onChange={(e) => e.target.value && addSingleCardToDeck(card, e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 rounded text-sm"
|
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<option value="">🃏 Add to Deck</option>
|
|
||||||
{decks.map(deck => (
|
|
||||||
<option key={deck.id} value={deck.id}>
|
|
||||||
{deck.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
|
|
||||||
<span>✅</span>
|
|
||||||
<span>Added to {card.processedAction}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue