deckhearth/pages/scanner.js
varutasu ca302a89c1
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)

The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.

Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).

Architect to address:
  - Q1: logged-out rendering branch design (navbar, mobile-nav,
        auth-only items treatment)
  - Q2: page audit triage into always-auth / public-or-auth /
        anonymous-allowed buckets
  - Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
  - Q4: whether to add vitest coverage for the logged-out branch
        (recommend yes — small surface, high regression protection)

Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).

depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
            drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness

Co-authored-by: Cursor <cursoragent@cursor.com>

* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)

2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).

Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
  UserProfileDropdown when logged out. Adds first jsdom test in the repo
  at test/components/Layout.test.js (Decision D2) with 5 regression-lock
  assertions. devDeps: jsdom@^29, @testing-library/react@^16.

Brief 2: page audit sweep — 7 pages need code changes:
  - Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
    deck/[id].js (×3), decks.js (×3)
  - Replace page-level useState({email: 'me@...'}) → useState(null) +
    null-guards: profile.js, settings.js
  - Replace hardcoded const user = {email: 'me@...'} with useAuth():
    card/[id].js

Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.

Decisions:
  A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
       hides auth-only dropdown (Profile/Settings/Logout/Admin);
       keeps public + community nav visible
  B  — Per-page bucket assignment (10 already correct, 7 need fix);
       full per-page table with justification in convoy file
  C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
       sweep depends on Brief 1). C1 buries the conceptual change under
       mechanical edits; C3 is over-orchestrated for this scope
  D2 — vitest lock-in; first jsdom test in repo; same negative-regression
       style as test/lib/permission-middleware.test.js (synthetic-admin
       shape). devDeps jsdom + @testing-library/react

Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).

MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.

Flagged-but-deferred:
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - Layout headers still render "Deck Hearth" / "DH" branding
    → pick-a-name (queued P1 #12)
  - MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
    or fold into god-component-split

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)

Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.

components/Layout.js:
  - Default user prop is now null (was hardcoded to
    { email: 'me@randallstillwell.com', role: 'user' })
  - UserProfileDropdown renders a "Sign in" link to /login when
    user === null instead of the maintainer's email + auth-only menu
    items (Decision A1)
  - All user.* accesses guarded with optional chaining or null checks
  - useState hook stays above the new null-user early return to satisfy
    rules-of-hooks (boot-the-brief caught this on the first try;
    see AGENTS.md Gotcha #11.5)

test/components/Layout.test.js (new):
  - First jsdom test in the repo (Decision D2)
  - 5 regression-lock assertions: no maintainer email ever rendered
    (prop omitted, prop=null), Sign-in link exists with href=/login,
    supplied email renders when prop is set, no "Guest" placeholder
    (locks A1 copy choice)
  - Mocks next/link, next/router (prefetch, replace, events, query),
    and theme-context.useTheme for jsdom safety under Next 16

package.json + package-lock.json:
  - Add jsdom@^29 and @testing-library/react@^16 to devDependencies
  - @testing-library/dom@^10 added explicitly (peer auto-install
    skipped it under npm 11; brief anticipated this fallback)

vitest.config.js (deviation from brief — see PR description):
  - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
    parse JSX in .js files. Required to import any React component
    written in the repo's Next.js pages-router .js convention
    (AGENTS.md Gotcha #9). The brief said "no change" to this file,
    but JSX-in-.js parsing is a hard prerequisite for the new test
    to import components/Layout.js — the alternatives (rename test
    to .test.jsx; rewrite test in React.createElement) either break
    the test glob or still hit the same Layout.js parse failure.
    Other tests are unaffected (they import non-JSX modules).

Smoke output: see PR description.

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)

Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.

Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.

Pass user={user} to Layout (4 pages, 11 call sites):
  - pages/scanner.js (1 call)
  - pages/decks.js (3 calls)
  - pages/deck-builder.js (4 calls)
  - pages/deck/[id].js (3 calls)
  (All four still import useAuth from lib/auth-context.js — that's
   intentional and stays as-is until the single-auth-provider convoy
   collapses the three parallel auth surfaces.)

Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
  - pages/profile.js: useState({email: 'me@...', role: 'user', ...})
                     → useState(null) + ?. on every sync user.* read
                     + early-return guards in getDisplayName/getInitials
                     + conditional render around the "Member since" block
                       so formatDate(undefined) never runs
  - pages/settings.js: same pattern (single user.email reader guarded)

Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
  - pages/card/[id].js: const user = {email: 'me@...'}
                       → const { user } = useAuth() (called unconditionally
                       at the top of the component; rules-of-hooks safe)

Verification:
  - grep 'me@randallstillwell.com' pages/ → 0 hits
  - 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
  - npm run lint matches baseline (128 problems pre, 128 post; verified
    via git stash before/after)
  - Manual static read-through of every diff; ReadLints clean on the 7
    files
  - Dev-server smoke: /cards anonymous returned HTTP 200 with 0
    'me@randallstillwell' matches before the user's shared dev server
    became unresponsive mid-session (same dev-server-shared-by-user
    constraint flagged in Brief 1); interactive logged-in smoke is
    parent/operator gated

Flagged-but-deferred (untouched per scope):
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - components/MobileNavigation.js still receives dead user prop
    → cleanup-mobile-nav-dead-props (or fold into god-component-split)

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:31:37 -05:00

776 lines
No EOL
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CameraScanner from '../components/CameraScanner';
import OCRSettings from '../components/OCRSettings';
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/auth-context';
export default function Scanner() {
const { user } = useAuth();
const router = useRouter();
const [scannedCards, setScannedCards] = useState([]);
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
const [showCreateCollection, setShowCreateCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [showOCRSettings, setShowOCRSettings] = useState(false);
// Bulk action states
const [selectedCards, setSelectedCards] = useState(new Set());
const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck'
const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Redirect to login if not authenticated
useEffect(() => {
if (!user) {
router.push('/login');
return;
}
}, [user, router]);
// Load collections and decks
useEffect(() => {
if (user) {
loadCollections();
loadDecks();
}
}, [user]);
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 system collections (like "All My Cards")
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) => {
console.log('Card scanned:', cardData);
console.log('Looking for existing card with name:', cardData.name, 'set:', cardData.set);
// Check if this card already exists in the queue
setScannedCards(prev => {
console.log('Current queue:', prev.map(c => ({ name: c.name, set: c.set, processed: c.processed })));
const existingCardIndex = prev.findIndex(existing =>
existing.name === cardData.name &&
existing.set === cardData.set &&
!existing.processed
);
if (existingCardIndex !== -1) {
console.log(`📈 Incrementing quantity for existing card: ${cardData.name}`);
// Increment quantity of existing card
const updatedCards = [...prev];
updatedCards[existingCardIndex] = {
...updatedCards[existingCardIndex],
quantity: (updatedCards[existingCardIndex].quantity || 1) + 1,
timestamp: new Date().toISOString() // Update timestamp
};
return updatedCards;
} else {
console.log(`🆕 Adding new card to queue: ${cardData.name}`);
// Add new card to queue
const scannedCard = {
...cardData,
id: Date.now() + Math.random(), // More unique ID for the queue
name: cardData.name,
set: cardData.set,
quantity: 1,
timestamp: new Date().toISOString(),
processed: false
};
return [scannedCard, ...prev];
}
});
};
// Quantity management functions
const incrementCardQuantity = (cardId) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, quantity: (card.quantity || 1) + 1 }
: card
));
};
const decrementCardQuantity = (cardId) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) }
: card
));
};
const handleError = (error) => {
console.error('Scanner error:', error);
// You could show a toast notification here
};
// Individual card actions
const addSingleCardToOwned = async (card) => {
try {
await addToOwnedCards(card);
markCardAsProcessed(card.id, 'owned');
} catch (error) {
console.error('Error adding card to owned:', error);
}
};
const addSingleCardToCollection = async (card, collectionId) => {
try {
await addToCollection(card, collectionId);
markCardAsProcessed(card.id, 'collection');
} catch (error) {
console.error('Error adding card to collection:', error);
}
};
const addSingleCardToDeck = async (card, deckId) => {
try {
await addToDeck(card, deckId);
markCardAsProcessed(card.id, 'deck');
} catch (error) {
console.error('Error adding card to deck:', error);
}
};
// Bulk actions
const handleBulkAction = async () => {
if (!bulkAction || selectedCards.size === 0) return;
setIsProcessing(true);
try {
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
for (const card of cardsToProcess) {
if (bulkAction === 'owned') {
await addToOwnedCards(card);
} else if (bulkAction === 'collection' && bulkTarget) {
await addToCollection(card, bulkTarget);
} else if (bulkAction === 'deck' && bulkTarget) {
await addToDeck(card, bulkTarget);
}
markCardAsProcessed(card.id, bulkAction);
}
// Clear selections and reset bulk action state
setSelectedCards(new Set());
setBulkAction('');
setBulkTarget('');
} catch (error) {
console.error('Error processing bulk action:', error);
} finally {
setIsProcessing(false);
}
};
const markCardAsProcessed = (cardId, action) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, processed: true, processedAction: action }
: card
));
};
// 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({
cardId: cardData.databaseId,
quantity: 1,
condition: 'NM'
})
});
if (!response.ok) {
throw new Error('Failed to add to owned cards');
}
};
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({
cardId: cardData.databaseId,
quantity: 1
})
});
if (!response.ok) {
throw new Error('Failed to add to collection');
}
};
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({
cardId: cardData.databaseId,
quantity: 1
})
});
if (!response.ok) {
throw new Error('Failed to add to deck');
}
};
const createCollection = async () => {
if (!newCollectionName.trim()) return;
try {
const response = await fetch('/api/collections', {
method: 'POST',
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());
setNewCollectionName('');
setShowCreateCollection(false);
}
} catch (error) {
console.error('Error creating collection:', error);
}
};
const clearScannedCards = () => {
setScannedCards([]);
setSelectedCards(new Set());
};
const removeScannedCard = (cardId) => {
setScannedCards(prev => prev.filter(card => card.id !== cardId));
setSelectedCards(prev => {
const newSet = new Set(prev);
newSet.delete(cardId);
return newSet;
});
};
const toggleCardSelection = (cardId) => {
setSelectedCards(prev => {
const newSet = new Set(prev);
if (newSet.has(cardId)) {
newSet.delete(cardId);
} else {
newSet.add(cardId);
}
return newSet;
});
};
const selectAllCards = () => {
const unprocessedCards = scannedCards.filter(card => !card.processed);
setSelectedCards(new Set(unprocessedCards.map(card => card.id)));
};
const deselectAllCards = () => {
setSelectedCards(new Set());
};
if (!user) {
return <div>Redirecting to login...</div>;
}
return (
<Layout user={user}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="px-6 pt-6 pb-4">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
🃏 Card Scanner
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Scan cards to identify them, then choose what to do with your collection
</p>
</div>
{/* Main Content - Full Height */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
{/* Camera Scanner */}
<div className="lg:col-span-3 flex flex-col">
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Camera Scanner
</h2>
<button
onClick={() => setShowOCRSettings(true)}
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
OCR Settings
</button>
</div>
<div className="flex-1">
<CameraScanner
onCardScanned={handleCardScanned}
onError={handleError}
/>
</div>
</div>
</div>
{/* Scanned Cards Queue */}
<div className="lg:col-span-2 flex flex-col">
<div className="flex-1 rounded-xl p-6 flex flex-col" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Scanned Cards
</h2>
<div className="flex items-center gap-2">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{scannedCards.length} cards
</div>
{scannedCards.length > 0 && (
<button
onClick={clearScannedCards}
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Clear All
</button>
)}
</div>
</div>
{/* Scanned Cards Queue - Scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-3">
{scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2">📱</div>
<div className="font-medium">No cards scanned yet</div>
<div className="text-sm">Start scanning to see cards here</div>
</div>
) : (
scannedCards.map((card) => (
<div
key={card.id}
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: selectedCards.has(card.id) ? 'var(--accent-ember)' : 'var(--border)',
borderWidth: selectedCards.has(card.id) ? '2px' : '1px'
}}
>
{/* Card Thumbnail with Checkbox Overlay */}
<div className="relative flex-shrink-0">
<div className="w-20 h-28 rounded-lg overflow-hidden bg-gray-200 flex items-center justify-center">
{card.image_url ? (
<img
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>
</div>
</div>
</div>
{/* Floating Bulk Actions Toolbar */}
{selectedCards.size > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
backdropFilter: 'blur(10px)'
}}>
{/* Selection Count */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: 'var(--accent-ember)' }}>
{selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Quick Actions */}
<div className="flex items-center gap-3">
<button
onClick={() => {
setBulkAction('owned');
handleBulkAction();
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
💎 Mark Owned
</button>
{collections.length > 0 && (
<select
onChange={(e) => {
if (e.target.value) {
setBulkAction('collection');
setBulkTarget(e.target.value);
setTimeout(() => handleBulkAction(), 100);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
>
<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) => {
if (e.target.value) {
setBulkAction('deck');
setBulkTarget(e.target.value);
setTimeout(() => handleBulkAction(), 100);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
>
<option value="">🃏 Add to Deck</option>
{decks.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Clear Selection */}
<button
onClick={() => setSelectedCards(new Set())}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
title="Clear Selection"
>
</button>
</div>
</div>
)}
{/* Bulk Actions Modal */}
{/* This modal is no longer needed as bulk actions are in a floating toolbar */}
{/* Create Collection Modal */}
{showCreateCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="rounded-xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection
</h3>
<input
type="text"
placeholder="Collection name..."
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
className="w-full px-4 py-2 rounded-lg border mb-4"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
createCollection();
}
}}
/>
<div className="flex gap-3">
<button
onClick={createCollection}
disabled={!newCollectionName.trim()}
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Create
</button>
<button
onClick={() => setShowCreateCollection(false)}
className="flex-1 px-4 py-2 rounded-lg border font-medium"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* OCR Settings Modal */}
{showOCRSettings && (
<OCRSettings onClose={() => setShowOCRSettings(false)} />
)}
</div>
</Layout>
);
}