deckhearth/components/ScannerPageView.js
Randall Stillwell d269bd29c1 feat(design-system): sweep authenticated body-content panels to glass
PR #95/#96 shipped the Liquid Glass foundation (tokens, primitives, gates)
plus Layout shell, modals, landing, auth pages, and form CTAs — but body-
content panels on authenticated pages (admin Card Editor, admin Card
Import, admin Submissions, dashboard, my-cards, settings, scanner panels,
card detail price cards, popovers) were still rendering as flat
var(--bg-secondary) cards. Result: the admin Tools screen and several
core pages looked unchanged after the redesign.

This sweep adds a `.glass-panel` / `.glass-panel-strong` utility
(<GlassSurface tint=mid/high rim=subtle elevation=ambient/pronounced
blur=mid/high> in class form) and applies it across 18 surfaces:

  * Admin Card Editor view, search panel, form (5 sections), preview
  * Admin Card Import navigation + 3 body cards + sync panel
  * Admin Card Submissions list items
  * Dashboard stat cards + empty-state + grid items (5 surfaces)
  * My-cards empty-state CTA card
  * Settings panels (3)
  * Scanner page settings + grid + queue + bulk toolbar + dialog
  * Scanner destination picker + camera status banner + disambiguation
  * Card detail price cards (Current / TCGPlayer / CardKingdom)
  * Permission indicator tooltips
  * Collections page header card
  * Card detail view price cards

Also migrates the lingering admin Card Editor "Card Editor / Card Import"
nav buttons and the "Save Changes" / "Import Cards" / "Run catalog sync"
CTAs to the <Button> primitive (consistent loading + disabled states).

Page header bands (full-bleed strips with border-bottom on dashboard,
my-cards, cards, collections, community/collections, collection/[id])
are intentionally left solid — they're not card-shaped surfaces and
stacking glass-on-glass directly below the already-glass topbar would
muddy the hierarchy.

Tests: lint clean, vitest 104/104, build green. The visual diff
baseline will need refresh because the homepage spec is unaffected
(it targets the unauthenticated landing page) but the dashboard/
admin/scanner surfaces will diff if/when we add baselines for them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:44:40 -05:00

327 lines
12 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.

import CameraScanner from './CameraScanner';
import ScannerDestinationPicker from './ScannerDestinationPicker';
import ScannedCardItem, { CONDITION_OPTIONS } from './ScannedCardItem';
import OCRSettings from './OCRSettings';
import { Modal, Input, Button } from './ui';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function ScannerPageView({
gameFilter,
onGameFilterChange,
sessionDestination,
onDestinationChange,
scanDefaults,
onScanDefaultsChange,
queue,
showOCRSettings,
onOpenOCRSettings,
onCloseOCRSettings,
onScannerError,
}) {
return (
<div className="h-full flex flex-col">
<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)' }}>
Pick a destination once every scan lands there until you change it
</p>
</div>
<ScannerDestinationPicker
gameFilter={gameFilter}
onGameFilterChange={onGameFilterChange}
destination={sessionDestination}
onDestinationChange={onDestinationChange}
collections={queue.collections}
decks={queue.decks}
disabled={queue.isProcessing}
/>
{queue.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"
>
{queue.autoRouteError}
</div>
)}
<div
className="glass-panel mx-6 mb-4 rounded-xl p-4 flex flex-wrap items-end gap-4"
>
<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) => onScanDefaultsChange({ 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) => onScanDefaultsChange({ isFoil: e.target.checked })}
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
<div className="lg:col-span-3 flex flex-col">
<div
className="glass-panel flex-1 rounded-xl p-6 flex flex-col"
>
<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={onOpenOCRSettings}
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={queue.handleCardScanned} onError={onScannerError} />
</div>
</div>
</div>
<div className="lg:col-span-2 flex flex-col">
<div
className="glass-panel flex-1 rounded-xl p-6 flex flex-col"
>
<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)' }}
aria-live="polite"
aria-atomic="true"
>
{queue.scannedCards.length} cards
</div>
{queue.scannedCards.length > 0 && (
<button
onClick={queue.clearScannedCards}
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
aria-label="Clear all scanned cards from queue"
>
Clear All
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{queue.scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
<div className="font-medium">No cards scanned yet</div>
<div className="text-sm">Start scanning to see cards here</div>
</div>
) : (
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
{queue.scannedCards.map((card) => (
<li key={card.id}>
<ScannedCardItem
card={card}
collections={queue.collections}
decks={queue.decks}
selected={queue.selectedCards.has(card.id)}
onToggleSelect={() => queue.toggleCardSelection(card.id)}
onIncrement={() => queue.incrementCardQuantity(card.id)}
onDecrement={() => queue.decrementCardQuantity(card.id)}
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
onMarkOwned={() => queue.addSingleCardToOwned(card)}
onAddToCollection={(collectionId) => queue.addSingleCardToCollection(card, collectionId)}
onAddToDeck={(deckId) => queue.addSingleCardToDeck(card, deckId)}
onRemove={() => queue.removeScannedCard(card.id)}
isAdding={queue.addingCardIds.has(card.id)}
/>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
{queue.selectedCards.size > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div
className="glass-panel-strong rounded-2xl px-6 py-4 flex items-center gap-4 max-w-4xl"
role="toolbar"
aria-label="Bulk actions for selected scanned cards"
>
<div className="flex items-center gap-2" aria-live="polite" aria-atomic="true">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: 'var(--accent-ember)' }}
>
{queue.selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{queue.selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<div className="flex items-center gap-3">
<button
onClick={() => queue.handleBulkAction('owned')}
disabled={queue.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' }}
>
<span aria-hidden="true">💎 </span>
{VOCAB.ADD_TO_MY_COLLECTION}
</button>
{queue.collections.length > 0 && (
<select
onChange={(e) => {
const collectionId = e.target.value;
e.target.value = '';
if (collectionId) {
queue.handleBulkAction('collection', collectionId);
}
}}
disabled={queue.isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
aria-label="Add selected cards to list"
>
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{queue.collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{queue.decks.length > 0 && (
<select
onChange={(e) => {
const deckId = e.target.value;
e.target.value = '';
if (deckId) {
queue.handleBulkAction('deck', deckId);
}
}}
disabled={queue.isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
aria-label="Add selected cards to deck"
>
<option value="">🃏 Add to Deck</option>
{queue.decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
<button
onClick={queue.clearSelection}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
aria-label="Clear selection"
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
)}
<Modal
open={queue.showCreateCollection}
onClose={() => queue.setShowCreateCollection(false)}
title="Create New List"
size="md"
>
<Input
id="create-collection-name"
label="List name"
placeholder="List name..."
value={queue.newCollectionName}
onChange={(e) => queue.setNewCollectionName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
queue.createCollection();
}
}}
className="mb-4"
/>
<div className="flex gap-3">
<Button
variant="primary"
onClick={queue.createCollection}
disabled={!queue.newCollectionName.trim()}
className="flex-1"
>
Create
</Button>
<Button
variant="secondary"
onClick={() => queue.setShowCreateCollection(false)}
className="flex-1"
>
Cancel
</Button>
</div>
</Modal>
{showOCRSettings && <OCRSettings onClose={onCloseOCRSettings} />}
</div>
);
}