deckhearth/components/scanner/ScannerHistoryStrip.js

344 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/* 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>
);
}