2026-05-27 14:44:47 -04:00
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
2025-07-29 15:19:48 -04:00
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Layout from '../components/Layout';
|
|
|
|
|
|
import CameraScanner from '../components/CameraScanner';
|
2026-05-27 14:50:54 -04:00
|
|
|
|
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
|
2026-05-27 14:54:33 -04:00
|
|
|
|
import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem';
|
2025-07-29 15:19:48 -04:00
|
|
|
|
import OCRSettings from '../components/OCRSettings';
|
|
|
|
|
|
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
|
|
|
|
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
refactor(auth): collapse lib/auth-context.js + lib/admin-auth.js onto lib/use-auth.js (#31)
`lib/use-auth.js` is now the sole client-side auth surface (P1 §9 of
`.convoys/ship-readiness.md`). The legacy `lib/auth-context.js`
(`AuthProvider` + `useAuth`) and `lib/admin-auth.js` (`AdminProvider` +
`useAdmin` + `useIsAdmin`) are deleted; every importer is migrated to
the canonical hook. Pre-convoy a worst-case page mount issued THREE
identical `GET /api/auth/verify` requests (one per provider/hook); the
post-convoy floor is one verify per page mount (3 → 1 on
`pages/card/[id].js`, 2 → 1 elsewhere).
Importer inventory swept (7 source files):
- `pages/_app.js` — removed `<AuthProvider>` wrapper; `<ThemeProvider>`
is now the only top-level provider. `lib/use-auth.js` is hook-only,
no replacement provider needed.
- `pages/index.js`, `pages/scanner.js`, `pages/decks.js`,
`pages/deck/[id].js`, `pages/deck-builder.js` — `import { useAuth }`
path swap from `../lib/auth-context` to `../lib/use-auth`. All five
pages destructured only `{ user }` or `{ user, loading }`; verified
no consumer reads `login` / `register` from useAuth (those flows are
in `pages/login.js` / `pages/signup.js` which call the API directly),
so no shape-parity gap on `lib/use-auth.js`.
- `pages/card/[id].js` — replaced `useIsAdmin()` (the only consumer of
`lib/admin-auth.js` anywhere in the tree) with synchronous
`user?.role === 'admin'` derived from the existing `useAuth()` call.
Render condition at line 524 stays byte-identical.
Decisions documented in `.convoys/single-auth-provider.md`:
- D1: no extension to `lib/use-auth.js` (zero call sites for `login` /
`register` from useAuth — those flows are direct fetches in
`login.js` / `signup.js`).
- D2: `useIsAdmin()` collapses onto `useAuth()`; no separate hook.
- D3: provider tree `<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>`
→ `<ThemeProvider>{children}</ThemeProvider>`.
- D4: 3 → 1 verify roundtrip on `card/[id].js`; 2 → 1 on every other
page-load.
- D5: zero test files modified; the 21-test vitest suite is server-
side or prop-driven (`Layout.test.js` passes `user` as a prop, never
imports the legacy hooks).
Doc / config updates so the deletion lands cleanly:
- `.github/CODEOWNERS` — drop the two CODEOWNERS lines for the deleted
files.
- `AGENTS.md` § 2 architecture row + § 3 "Auth (client)" bullet —
rewritten for the post-convoy single-surface state.
- `.cursor/rules/auth-and-permissions.mdc` — § "Legacy" reframed to
"deleted by this convoy"; § "Authentication state on the client"
updated to the post-convoy `useAuth()` shape and the direct-fetch
login flow used by `login.js` / `signup.js`.
- `.cursor/rules/no-go-zones.mdc` — auth-refactors bullet drops the
deleted files from the canonical list.
- `.cursor/skills/add-page/SKILL.md` — checklist + anti-pattern row
refer to the deletion.
Verification:
- `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in source.
- `npm run lint` → 128 → 125 problems (3 fewer errors from the deleted
unused-import lines; no regression).
- `npm run test:run` → 21/21 pass (including the 5 Layout regression
locks from `fix-layout-default-user`, which are prop-driven and
unaffected).
- `npm run build` → all 26 pages compile end-to-end; no SSR / static-
generation breakage that would have surfaced if a page tried to use
the legacy context hook unwrapped.
- Manual smoke deferred to operator post-merge per convoy doc.
Risks (full discussion in convoy file):
- R1 shape parity gap — verified zero consumers of legacy-only
surface; mitigated.
- R2 SSR mismatch from removing `<AuthProvider>` — `useEffect`-
guarded `localStorage` read; identical SSR shape pre/post; build
passes.
- R3 missed importer — post-delete grep + build pass would surface
any miss.
- R5 stale `useAuth` cache across components — pre-existing
pattern, called out as follow-up rather than addressed here.
Out of scope: any change to `lib/permission-middleware.js` (server-
side; resolved P0 #1), `lib/auth-secret.js` (resolved P0 #2),
`pages/api/**` route handlers, login / register API contracts, or
the seeded admin account flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 23:58:08 -04:00
|
|
|
|
import { useAuth } from '../lib/use-auth';
|
2026-05-27 15:22:18 -04:00
|
|
|
|
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
2026-05-27 14:50:54 -04:00
|
|
|
|
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
|
2026-05-27 14:54:33 -04:00
|
|
|
|
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
2026-05-27 14:50:54 -04:00
|
|
|
|
|
|
|
|
|
|
function loadSavedScannerSession() {
|
|
|
|
|
|
if (typeof window === 'undefined') {
|
2026-05-27 14:54:33 -04:00
|
|
|
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
2026-05-27 14:50:54 -04:00
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
|
|
|
|
|
|
return {
|
|
|
|
|
|
destination: saved?.destination || DEFAULT_DESTINATION,
|
|
|
|
|
|
gameFilter: saved?.gameFilter || 'All',
|
2026-05-27 14:54:33 -04:00
|
|
|
|
scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS,
|
2026-05-27 14:50:54 -04:00
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
2026-05-27 14:54:33 -04:00
|
|
|
|
return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS };
|
2026-05-27 14:50:54 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function destinationActionKey(destination) {
|
|
|
|
|
|
if (!destination) return 'pending';
|
|
|
|
|
|
return destination.type === 'owned' ? 'owned' : destination.type;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
export default function Scanner() {
|
2026-05-27 13:36:58 -04:00
|
|
|
|
const { user, loading: authLoading } = useAuth();
|
2025-07-29 15:19:48 -04:00
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const [scannedCards, setScannedCards] = useState([]);
|
|
|
|
|
|
const [collections, setCollections] = useState([]);
|
|
|
|
|
|
const [decks, setDecks] = useState([]);
|
|
|
|
|
|
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
|
|
|
|
|
const [newCollectionName, setNewCollectionName] = useState('');
|
2026-05-27 15:22:18 -04:00
|
|
|
|
const createCollectionDialogRef = useFocusTrap(showCreateCollection);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
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);
|
2026-05-27 14:44:47 -04:00
|
|
|
|
const addingInFlightRef = useRef(new Set());
|
2026-05-28 15:22:06 -04:00
|
|
|
|
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
2026-05-27 14:50:54 -04:00
|
|
|
|
const [sessionDestination, setSessionDestination] = useState(
|
|
|
|
|
|
() => loadSavedScannerSession().destination
|
|
|
|
|
|
);
|
|
|
|
|
|
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
|
2026-05-27 14:54:33 -04:00
|
|
|
|
const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
|
2026-05-27 14:50:54 -04:00
|
|
|
|
const [autoRouteError, setAutoRouteError] = useState(null);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
|
|
// Mana symbol settings
|
|
|
|
|
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
|
|
|
|
|
2026-05-27 13:36:58 -04:00
|
|
|
|
// Redirect to login if not authenticated (wait for verify to finish)
|
2025-07-29 15:19:48 -04:00
|
|
|
|
useEffect(() => {
|
2026-05-27 13:36:58 -04:00
|
|
|
|
if (!authLoading && !user) {
|
2025-07-29 15:19:48 -04:00
|
|
|
|
router.push('/login');
|
|
|
|
|
|
}
|
2026-05-27 13:36:58 -04:00
|
|
|
|
}, [authLoading, user, router]);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
|
|
// Load collections and decks
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
loadCollections();
|
|
|
|
|
|
loadDecks();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
2026-05-27 14:50:54 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
|
SESSION_STORAGE_KEY,
|
2026-05-27 14:54:33 -04:00
|
|
|
|
JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults })
|
2026-05-27 14:50:54 -04:00
|
|
|
|
);
|
2026-05-27 14:54:33 -04:00
|
|
|
|
}, [sessionDestination, gameFilter, scanDefaults]);
|
2026-05-27 14:50:54 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
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) => {
|
2026-05-27 14:50:54 -04:00
|
|
|
|
setAutoRouteError(null);
|
|
|
|
|
|
|
|
|
|
|
|
const existingCardIndex = scannedCards.findIndex((existing) =>
|
|
|
|
|
|
existing.name === cardData.name &&
|
|
|
|
|
|
existing.set === cardData.set &&
|
|
|
|
|
|
!existing.processed
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let cardEntry;
|
|
|
|
|
|
|
|
|
|
|
|
if (existingCardIndex !== -1) {
|
|
|
|
|
|
const existing = scannedCards[existingCardIndex];
|
|
|
|
|
|
cardEntry = {
|
|
|
|
|
|
...existing,
|
|
|
|
|
|
quantity: (existing.quantity || 1) + 1,
|
2026-05-27 14:57:26 -04:00
|
|
|
|
scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl,
|
2026-05-27 14:50:54 -04:00
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
setScannedCards((prev) => {
|
|
|
|
|
|
const updated = [...prev];
|
|
|
|
|
|
updated[existingCardIndex] = cardEntry;
|
|
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cardEntry = {
|
|
|
|
|
|
...cardData,
|
|
|
|
|
|
id: Date.now() + Math.random(),
|
|
|
|
|
|
name: cardData.name,
|
|
|
|
|
|
set: cardData.set,
|
|
|
|
|
|
quantity: 1,
|
2026-05-27 14:54:33 -04:00
|
|
|
|
condition: scanDefaults.condition,
|
|
|
|
|
|
isFoil: scanDefaults.isFoil,
|
2026-05-27 14:50:54 -04:00
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
processed: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
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 {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(cardEntry.id)) return;
|
2026-05-27 14:50:54 -04:00
|
|
|
|
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.`);
|
2026-05-28 15:22:06 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
endAdding(cardEntry.id);
|
2026-05-27 14:50:54 -04:00
|
|
|
|
}
|
2025-07-29 15:19:48 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 15:22:06 -04:00
|
|
|
|
const syncAddingState = () => {
|
|
|
|
|
|
setAddingCardIds(new Set(addingInFlightRef.current));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const tryBeginAdding = (cardId) => {
|
|
|
|
|
|
if (addingInFlightRef.current.has(cardId)) return false;
|
|
|
|
|
|
addingInFlightRef.current.add(cardId);
|
|
|
|
|
|
syncAddingState();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const endAdding = (cardId) => {
|
|
|
|
|
|
addingInFlightRef.current.delete(cardId);
|
|
|
|
|
|
syncAddingState();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
// Individual card actions
|
|
|
|
|
|
const addSingleCardToOwned = async (card) => {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) return;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await addToOwnedCards(card);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'owned');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to owned:', error);
|
2026-05-27 14:44:47 -04:00
|
|
|
|
} finally {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
endAdding(card.id);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSingleCardToCollection = async (card, collectionId) => {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) return;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await addToCollection(card, collectionId);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'collection');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to collection:', error);
|
2026-05-28 15:22:06 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
endAdding(card.id);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSingleCardToDeck = async (card, deckId) => {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) return;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await addToDeck(card, deckId);
|
|
|
|
|
|
markCardAsProcessed(card.id, 'deck');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to deck:', error);
|
2026-05-28 15:22:06 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
endAdding(card.id);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bulk actions
|
2026-05-27 14:44:47 -04:00
|
|
|
|
const handleBulkAction = async (actionOverride, targetOverride) => {
|
|
|
|
|
|
const action = actionOverride ?? bulkAction;
|
|
|
|
|
|
const target = targetOverride ?? bulkTarget;
|
|
|
|
|
|
if (!action || selectedCards.size === 0) return;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
|
2026-05-27 14:44:47 -04:00
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
for (const card of cardsToProcess) {
|
2026-05-27 14:44:47 -04:00
|
|
|
|
if (action === 'owned') {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) continue;
|
2026-05-27 14:44:47 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await addToOwnedCards(card);
|
|
|
|
|
|
} finally {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
endAdding(card.id);
|
2026-05-27 14:44:47 -04:00
|
|
|
|
}
|
|
|
|
|
|
} else if (action === 'collection' && target) {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addToCollection(card, target);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
endAdding(card.id);
|
|
|
|
|
|
}
|
2026-05-27 14:44:47 -04:00
|
|
|
|
} else if (action === 'deck' && target) {
|
2026-05-28 15:22:06 -04:00
|
|
|
|
if (!tryBeginAdding(card.id)) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await addToDeck(card, target);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
endAdding(card.id);
|
|
|
|
|
|
}
|
2026-05-27 14:44:47 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
continue;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
2026-05-27 14:44:47 -04:00
|
|
|
|
markCardAsProcessed(card.id, action);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 14:54:33 -04:00
|
|
|
|
const updateCardMetadata = (cardId, patch) => {
|
|
|
|
|
|
setScannedCards((prev) =>
|
|
|
|
|
|
prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card))
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 14:57:26 -04:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-05-27 14:54:33 -04:00
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
// Helper functions for API calls
|
|
|
|
|
|
const addToOwnedCards = async (cardData) => {
|
|
|
|
|
|
const response = await fetch('/api/user-cards', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-05-27 14:54:33 -04:00
|
|
|
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
2025-07-29 15:19:48 -04:00
|
|
|
|
},
|
2026-05-27 14:54:33 -04:00
|
|
|
|
body: JSON.stringify(buildCardPayload(cardData)),
|
2025-07-29 15:19:48 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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')}`
|
|
|
|
|
|
},
|
2026-05-27 14:54:33 -04:00
|
|
|
|
body: JSON.stringify(buildCardPayload(cardData)),
|
2025-07-29 15:19:48 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
2026-05-27 14:54:33 -04:00
|
|
|
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
2025-07-29 15:19:48 -04:00
|
|
|
|
},
|
2026-05-27 14:54:33 -04:00
|
|
|
|
body: JSON.stringify(buildCardPayload(cardData)),
|
2025-07-29 15:19:48 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 13:36:58 -04:00
|
|
|
|
if (authLoading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout user={null}>
|
|
|
|
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--text-accent)' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
if (!user) {
|
|
|
|
|
|
return <div>Redirecting to login...</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
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 15:31:37 -04:00
|
|
|
|
<Layout user={user}>
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="h-full flex flex-col">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{/* Header */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="px-6 pt-6 pb-4">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<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)' }}>
|
2026-05-27 14:50:54 -04:00
|
|
|
|
Pick a destination once — every scan lands there until you change it
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-27 14:50:54 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-27 14:54:33 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
{/* Main Content - Full Height */}
|
|
|
|
|
|
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{/* Camera Scanner */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<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)' }}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<CameraScanner
|
|
|
|
|
|
onCardScanned={handleCardScanned}
|
|
|
|
|
|
onError={handleError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Scanned Cards Queue */}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
<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)' }}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<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">
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<div
|
|
|
|
|
|
className="text-sm"
|
|
|
|
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
|
aria-atomic="true"
|
|
|
|
|
|
>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
|
{/* Scanned Cards Queue - Scrollable */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
2025-07-29 15:19:48 -04:00
|
|
|
|
{scannedCards.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="font-medium">No cards scanned yet</div>
|
|
|
|
|
|
<div className="text-sm">Start scanning to see cards here</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
|
|
|
|
|
|
{scannedCards.map((card) => (
|
|
|
|
|
|
<li key={card.id}>
|
|
|
|
|
|
<ScannedCardItem
|
|
|
|
|
|
card={card}
|
|
|
|
|
|
collections={collections}
|
|
|
|
|
|
decks={decks}
|
|
|
|
|
|
selected={selectedCards.has(card.id)}
|
|
|
|
|
|
onToggleSelect={() => toggleCardSelection(card.id)}
|
|
|
|
|
|
onIncrement={() => incrementCardQuantity(card.id)}
|
|
|
|
|
|
onDecrement={() => decrementCardQuantity(card.id)}
|
|
|
|
|
|
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
|
|
|
|
|
onMarkOwned={() => addSingleCardToOwned(card)}
|
|
|
|
|
|
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
|
|
|
|
|
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
|
|
|
|
|
onRemove={() => removeScannedCard(card.id)}
|
2026-05-28 15:22:06 -04:00
|
|
|
|
isAdding={addingCardIds.has(card.id)}
|
2026-05-27 15:22:18 -04:00
|
|
|
|
/>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</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
|
2026-05-27 14:44:47 -04:00
|
|
|
|
onClick={() => handleBulkAction('owned')}
|
2025-07-29 15:19:48 -04:00
|
|
|
|
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' }}
|
|
|
|
|
|
>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<span aria-hidden="true">💎 </span>
|
|
|
|
|
|
Mark Owned
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{collections.length > 0 && (
|
|
|
|
|
|
<select
|
|
|
|
|
|
onChange={(e) => {
|
2026-05-27 14:44:47 -04:00
|
|
|
|
const collectionId = e.target.value;
|
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
|
if (collectionId) {
|
|
|
|
|
|
handleBulkAction('collection', collectionId);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="px-4 py-2 rounded-lg font-medium"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label="Add selected cards to collection"
|
2025-07-29 15:19:48 -04:00
|
|
|
|
>
|
|
|
|
|
|
<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) => {
|
2026-05-27 14:44:47 -04:00
|
|
|
|
const deckId = e.target.value;
|
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
|
if (deckId) {
|
|
|
|
|
|
handleBulkAction('deck', deckId);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="px-4 py-2 rounded-lg font-medium"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label="Add selected cards to deck"
|
2025-07-29 15:19:48 -04:00
|
|
|
|
>
|
|
|
|
|
|
<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)' }}
|
2026-05-27 15:22:18 -04:00
|
|
|
|
aria-label="Clear selection"
|
2025-07-29 15:19:48 -04:00
|
|
|
|
>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<span aria-hidden="true">✕</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
</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">
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<div
|
|
|
|
|
|
ref={createCollectionDialogRef}
|
|
|
|
|
|
className="rounded-xl p-6 max-w-md w-full mx-4"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="create-collection-title"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
Create New Collection
|
|
|
|
|
|
</h3>
|
2026-05-27 15:22:18 -04:00
|
|
|
|
<label htmlFor="create-collection-name" className="sr-only">
|
|
|
|
|
|
Collection name
|
|
|
|
|
|
</label>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<input
|
2026-05-27 15:22:18 -04:00
|
|
|
|
id="create-collection-name"
|
2025-07-29 15:19:48 -04:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|