deckhearth/components/scanner/ScannerHistoryStrip.js
varutasu 938c161a26
feat(scanner): add desktop workstation layout (#165)
Give /scanner a md+ camera, live match inspector, and history strip
(with device picker, batch scan, and tips) without regressing the
mobile immersive checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 17:21:23 -05:00

343 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

/* eslint-disable @next/next/no-img-element -- External card thumbnails; next/image migration is out of scope. */
import { useRef } from 'react';
import { VOCAB } from '../../lib/collection-vocabulary.js';
import Button from '../ui/Button.js';
import GlassSurface from '../ui/GlassSurface.js';
export const TAB_RECENT = 'recent';
export const TAB_QUEUE = 'queue';
export const TAB_DUPLICATES = 'duplicates';
const EMPTY_COPY = {
[TAB_RECENT]: 'No scans yet this session.',
[TAB_QUEUE]: 'Scan queue is empty — matches appear here before you add them.',
[TAB_DUPLICATES]: 'No duplicates detected.',
};
export function getDuplicateCards(scannedCards, ownershipMap) {
const sessionRepeatIds = new Set();
const seen = new Map();
for (const card of scannedCards) {
const key = `${card.name}::${card.set}`;
if (seen.has(key)) sessionRepeatIds.add(card.id);
else seen.set(key, card.id);
if ((card.quantity || 1) > 1) sessionRepeatIds.add(card.id);
}
return scannedCards.filter(
(card) =>
sessionRepeatIds.has(card.id) ||
(card.databaseId && ownershipMap[card.databaseId])
);
}
function queueTabAriaLabel(count) {
if (count === 0) return 'Scan Queue';
if (count === 1) return 'Scan Queue, 1 unprocessed card';
return `Scan Queue, ${count} unprocessed cards`;
}
function duplicatesTabAriaLabel(count) {
if (count === 0) return 'Duplicates';
if (count === 1) return 'Duplicates, 1 duplicate card';
return `Duplicates, ${count} duplicate cards`;
}
function HistoryChip({ card, isFocused, onFocus }) {
const isFailed = Boolean(card.identifyFailed);
return (
<button
type="button"
onClick={() => onFocus(card.id)}
className="flex-shrink-0 flex items-center gap-2 rounded-xl px-3 py-2 min-h-[44px] cursor-pointer transition-shadow duration-150 hover:shadow-[var(--rim-light-inner),var(--ember-rim-subtle)] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderLeft: isFailed ? '3px solid var(--accent-ember)' : undefined,
opacity: card.processed ? 0.65 : 1,
boxShadow: isFocused
? 'var(--rim-light-inner), var(--ember-rim-pronounced)'
: undefined,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-current={isFocused ? 'true' : undefined}
>
{card.image_url ? (
<img
src={card.image_url}
alt=""
className="h-10 w-7 rounded object-cover flex-shrink-0"
/>
) : (
<div
className="h-10 w-7 rounded flex-shrink-0"
style={{ backgroundColor: 'var(--bg-primary)' }}
aria-hidden="true"
/>
)}
<div className="min-w-0 text-left">
<div
className="text-sm font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</div>
{isFailed && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-ember)' }}>
Identify failed
</div>
)}
{isFailed && card.identifyFailureReason && (
<div className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
{card.identifyFailureReason}
</div>
)}
{!isFailed && card.set && (
<div className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{card.set}
</div>
)}
</div>
{(card.quantity || 1) > 1 && (
<span
className="text-xs font-semibold rounded-full px-2 py-0.5 flex-shrink-0"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)',
}}
>
×{card.quantity}
</span>
)}
</button>
);
}
function BatchProgressBanner({ batchProgress }) {
if (!batchProgress?.active) return null;
const { current, total, onCancel } = batchProgress;
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div
className="mb-3 rounded-xl px-4 py-3"
style={{ backgroundColor: 'var(--bg-secondary)' }}
aria-live="polite"
>
<div className="flex items-center justify-between gap-3 mb-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Scanning {current} of {total}
</span>
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-primary)' }}
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={total}
aria-label={`Scanning ${current} of ${total}`}
>
<div
className="h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none"
style={{
width: `${percent}%`,
background:
'linear-gradient(90deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
}}
/>
</div>
</div>
);
}
export default function ScannerHistoryStrip({
scannedCards,
ownershipMap,
focusedCardId,
onFocusCard,
activeTab,
onTabChange,
batchProgress = null,
onClearAll,
onCommitSelectedToOwned,
onOpenListPicker,
isProcessing = false,
}) {
const chipScrollerRef = useRef(null);
const unprocessedCards = scannedCards.filter((card) => !card.processed);
const duplicateCards = getDuplicateCards(scannedCards, ownershipMap || {});
const recentCards = [...scannedCards].reverse();
const tabCards = {
[TAB_RECENT]: recentCards,
[TAB_QUEUE]: unprocessedCards,
[TAB_DUPLICATES]: duplicateCards,
};
const visibleCards = tabCards[activeTab] ?? [];
const handleViewAllScans = () => {
const scroller = chipScrollerRef.current;
if (scroller) {
scroller.scrollLeft = scroller.scrollWidth;
}
};
const tabs = [
{
id: TAB_RECENT,
label: 'Recent Scans',
badge: null,
ariaLabel: 'Recent Scans',
},
{
id: TAB_QUEUE,
label: 'Scan Queue',
badge: unprocessedCards.length,
ariaLabel: queueTabAriaLabel(unprocessedCards.length),
},
{
id: TAB_DUPLICATES,
label: 'Duplicates',
badge: duplicateCards.length,
ariaLabel: duplicatesTabAriaLabel(duplicateCards.length),
},
];
return (
<GlassSurface tint="mid" className="rounded-2xl p-4 w-full">
<div className="flex items-center justify-between gap-4 mb-3">
<div role="tablist" aria-label="Scan history" className="flex gap-1 flex-wrap">
{tabs.map((tab) => {
const isSelected = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={`panel-${tab.id}`}
aria-label={tab.ariaLabel}
onClick={() => onTabChange(tab.id)}
className="relative px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
color: isSelected ? 'var(--accent-ember)' : 'var(--text-secondary)',
backgroundColor: isSelected
? 'color-mix(in srgb, var(--accent-ember) 12%, transparent)'
: 'transparent',
boxShadow: isSelected
? 'inset 0 -2px 0 var(--accent-ember)'
: undefined,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span aria-hidden="true">
{tab.label}
{tab.badge != null && tab.badge > 0 && (
<span
className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold"
style={{
backgroundColor:
'color-mix(in srgb, var(--accent-ember) 18%, var(--bg-secondary))',
color: 'var(--accent-ember)',
}}
>
{tab.badge}
</span>
)}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={onClearAll}
disabled={batchProgress?.active}
className="text-sm font-medium whitespace-nowrap transition-opacity duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
Clear All
</button>
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
>
{tab.id === TAB_QUEUE && <BatchProgressBanner batchProgress={batchProgress} />}
{visibleCards.length === 0 && activeTab === tab.id ? (
<p
className="text-sm py-4 text-center"
style={{ color: 'var(--text-secondary)' }}
>
{EMPTY_COPY[tab.id]}
</p>
) : activeTab === tab.id ? (
<div
ref={chipScrollerRef}
className="flex gap-2 overflow-x-auto pb-1 min-h-[44px]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{visibleCards.map((card) => (
<HistoryChip
key={card.id}
card={card}
isFocused={focusedCardId === card.id}
onFocus={onFocusCard}
/>
))}
</div>
) : null}
{tab.id === TAB_QUEUE && activeTab === TAB_QUEUE && unprocessedCards.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
<Button
variant="primary"
size="sm"
loading={isProcessing}
disabled={isProcessing}
onClick={onCommitSelectedToOwned}
>
{VOCAB.ADD_TO_MY_COLLECTION}
</Button>
<Button
variant="secondary"
size="sm"
disabled={isProcessing}
onClick={onOpenListPicker}
>
{VOCAB.ADD_TO_LIST}
</Button>
</div>
)}
</div>
))}
<div className="mt-3 flex justify-center">
<Button variant="ghost" size="sm" onClick={handleViewAllScans}>
View All Scans
</Button>
</div>
</GlassSurface>
);
}