344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
|
|
/* 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>
|
|||
|
|
);
|
|||
|
|
}
|