380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
|
|
import { useState, useId } from 'react';
|
||
|
|
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
|
||
|
|
import { Button } from '../ui';
|
||
|
|
|
||
|
|
const GAMES = [
|
||
|
|
{ value: 'All', label: 'Auto-detect' },
|
||
|
|
{ value: 'mtg', label: 'Magic' },
|
||
|
|
{ value: 'pokemon', label: 'Pokémon' },
|
||
|
|
{ value: 'lorcana', label: 'Lorcana' },
|
||
|
|
];
|
||
|
|
|
||
|
|
const DECK_SIZES = [
|
||
|
|
{ value: 40, label: '40' },
|
||
|
|
{ value: 60, label: '60' },
|
||
|
|
{ value: 99, label: '99' },
|
||
|
|
];
|
||
|
|
|
||
|
|
const DESTINATION_TYPES = [
|
||
|
|
{ value: 'owned', label: VOCAB.MY_COLLECTION },
|
||
|
|
{ value: 'collection', label: VOCAB.LIST },
|
||
|
|
{ value: 'deck', label: 'Deck' },
|
||
|
|
];
|
||
|
|
|
||
|
|
function ToggleGroup({ options, value, onChange, name, className = '' }) {
|
||
|
|
return (
|
||
|
|
<div className={`inline-flex rounded-xl overflow-hidden ${className}`} role="radiogroup" aria-label={name}>
|
||
|
|
{options.map((opt) => {
|
||
|
|
const selected = opt.value === value;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={opt.value}
|
||
|
|
type="button"
|
||
|
|
role="radio"
|
||
|
|
aria-checked={selected}
|
||
|
|
onClick={() => onChange(opt.value)}
|
||
|
|
className="px-4 py-2.5 text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||
|
|
style={{
|
||
|
|
backgroundColor: selected ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||
|
|
color: selected ? '#ffffff' : 'var(--text-secondary)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{opt.label}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ScanHistoryList({ history }) {
|
||
|
|
const [expanded, setExpanded] = useState(false);
|
||
|
|
const panelId = useId();
|
||
|
|
|
||
|
|
if (!history || history.length === 0) return null;
|
||
|
|
|
||
|
|
const formatDate = (iso) => {
|
||
|
|
try {
|
||
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
||
|
|
month: 'short',
|
||
|
|
day: 'numeric',
|
||
|
|
hour: 'numeric',
|
||
|
|
minute: '2-digit',
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
return 'Unknown date';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const destinationLabel = (dest) => {
|
||
|
|
if (!dest) return 'Unknown';
|
||
|
|
if (dest.type === 'owned') return VOCAB.MY_COLLECTION;
|
||
|
|
return dest.label || dest.type;
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mt-6">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setExpanded((v) => !v)}
|
||
|
|
aria-expanded={expanded}
|
||
|
|
aria-controls={panelId}
|
||
|
|
className="flex items-center gap-2 text-sm font-medium w-full py-2 focus:outline-none focus-visible:ring-2 rounded-lg px-1 min-h-[44px]"
|
||
|
|
style={{
|
||
|
|
color: 'var(--text-secondary)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
width="16"
|
||
|
|
height="16"
|
||
|
|
viewBox="0 0 16 16"
|
||
|
|
fill="none"
|
||
|
|
aria-hidden="true"
|
||
|
|
className="transition-transform duration-200"
|
||
|
|
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||
|
|
>
|
||
|
|
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
|
|
</svg>
|
||
|
|
Recent Sessions
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{expanded && (
|
||
|
|
<ul id={panelId} className="mt-2 space-y-2">
|
||
|
|
{history.map((entry, i) => (
|
||
|
|
<li
|
||
|
|
key={entry.date || i}
|
||
|
|
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm"
|
||
|
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||
|
|
>
|
||
|
|
<div className="flex flex-col gap-0.5">
|
||
|
|
<span style={{ color: 'var(--text-primary)' }}>
|
||
|
|
{entry.cardCount} {entry.cardCount === 1 ? 'card' : 'cards'}
|
||
|
|
</span>
|
||
|
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
{destinationLabel(entry.destination)}
|
||
|
|
{entry.game && entry.game !== 'All' ? ` · ${entry.game}` : ''}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<span className="text-xs tabular-nums" style={{ color: 'var(--text-tertiary)' }}>
|
||
|
|
{formatDate(entry.date)}
|
||
|
|
</span>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ScannerSetup({
|
||
|
|
sessionDestination,
|
||
|
|
onDestinationChange,
|
||
|
|
gameFilter,
|
||
|
|
onGameFilterChange,
|
||
|
|
collections,
|
||
|
|
decks,
|
||
|
|
deckMode,
|
||
|
|
onDeckModeChange,
|
||
|
|
onStartScanning,
|
||
|
|
scanHistory,
|
||
|
|
}) {
|
||
|
|
const destSelectId = useId();
|
||
|
|
const gameGroupId = useId();
|
||
|
|
const deckModeId = useId();
|
||
|
|
const deckSizeId = useId();
|
||
|
|
|
||
|
|
const destinationType = sessionDestination?.type ?? 'owned';
|
||
|
|
|
||
|
|
const handleDestinationTypeChange = (type) => {
|
||
|
|
if (type === 'owned') {
|
||
|
|
onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION });
|
||
|
|
} else if (type === 'collection') {
|
||
|
|
const first = collections?.[0];
|
||
|
|
onDestinationChange(
|
||
|
|
first
|
||
|
|
? { type: 'collection', id: first.id, label: collectionDisplayName(first) }
|
||
|
|
: { type: 'collection', id: null, label: VOCAB.LIST }
|
||
|
|
);
|
||
|
|
} else if (type === 'deck') {
|
||
|
|
const first = decks?.[0];
|
||
|
|
onDestinationChange(
|
||
|
|
first
|
||
|
|
? { type: 'deck', id: first.id, label: first.name }
|
||
|
|
: { type: 'deck', id: null, label: 'Deck' }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleListSelect = (e) => {
|
||
|
|
const id = parseInt(e.target.value, 10);
|
||
|
|
const found = collections.find((c) => c.id === id);
|
||
|
|
if (found) {
|
||
|
|
onDestinationChange({
|
||
|
|
type: 'collection',
|
||
|
|
id: found.id,
|
||
|
|
label: collectionDisplayName(found),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDeckSelect = (e) => {
|
||
|
|
const id = parseInt(e.target.value, 10);
|
||
|
|
const found = decks.find((d) => d.id === id);
|
||
|
|
if (found) {
|
||
|
|
onDestinationChange({ type: 'deck', id: found.id, label: found.name });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDeckModeToggle = () => {
|
||
|
|
if (deckMode) {
|
||
|
|
onDeckModeChange(null);
|
||
|
|
} else {
|
||
|
|
onDeckModeChange({ game: gameFilter, targetSize: 60 });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDeckSizeChange = (size) => {
|
||
|
|
onDeckModeChange({ ...deckMode, targetSize: size });
|
||
|
|
};
|
||
|
|
|
||
|
|
const selectStyle = {
|
||
|
|
backgroundColor: 'var(--bg-tertiary)',
|
||
|
|
color: 'var(--text-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="px-4 sm:px-6 py-6 max-w-lg mx-auto">
|
||
|
|
<div className="glass-panel rounded-2xl p-5 sm:p-6 space-y-6">
|
||
|
|
<header>
|
||
|
|
<h1
|
||
|
|
className="text-xl sm:text-2xl font-semibold"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
Card Scanner
|
||
|
|
</h1>
|
||
|
|
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Choose where scanned cards will go
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{/* Destination picker */}
|
||
|
|
<fieldset>
|
||
|
|
<legend className="text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Destination
|
||
|
|
</legend>
|
||
|
|
<ToggleGroup
|
||
|
|
options={DESTINATION_TYPES}
|
||
|
|
value={destinationType}
|
||
|
|
onChange={handleDestinationTypeChange}
|
||
|
|
name="Scan destination"
|
||
|
|
className="w-full"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{destinationType === 'collection' && (
|
||
|
|
<div className="mt-3">
|
||
|
|
<label htmlFor={destSelectId} className="sr-only">
|
||
|
|
Choose a list
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
id={destSelectId}
|
||
|
|
value={sessionDestination?.id ?? ''}
|
||
|
|
onChange={handleListSelect}
|
||
|
|
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
|
||
|
|
style={{
|
||
|
|
...selectStyle,
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{(!collections || collections.length === 0) && (
|
||
|
|
<option value="" disabled>
|
||
|
|
No lists available
|
||
|
|
</option>
|
||
|
|
)}
|
||
|
|
{collections?.map((c) => (
|
||
|
|
<option key={c.id} value={c.id}>
|
||
|
|
{collectionDisplayName(c)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{destinationType === 'deck' && (
|
||
|
|
<div className="mt-3">
|
||
|
|
<label htmlFor={destSelectId} className="sr-only">
|
||
|
|
Choose a deck
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
id={destSelectId}
|
||
|
|
value={sessionDestination?.id ?? ''}
|
||
|
|
onChange={handleDeckSelect}
|
||
|
|
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
|
||
|
|
style={{
|
||
|
|
...selectStyle,
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{(!decks || decks.length === 0) && (
|
||
|
|
<option value="" disabled>
|
||
|
|
No decks available
|
||
|
|
</option>
|
||
|
|
)}
|
||
|
|
{decks?.map((d) => (
|
||
|
|
<option key={d.id} value={d.id}>
|
||
|
|
{d.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</fieldset>
|
||
|
|
|
||
|
|
{/* Game filter */}
|
||
|
|
<fieldset>
|
||
|
|
<legend
|
||
|
|
id={gameGroupId}
|
||
|
|
className="text-sm font-medium mb-2"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
Game
|
||
|
|
</legend>
|
||
|
|
<ToggleGroup
|
||
|
|
options={GAMES}
|
||
|
|
value={gameFilter}
|
||
|
|
onChange={onGameFilterChange}
|
||
|
|
name="Game filter"
|
||
|
|
className="w-full"
|
||
|
|
/>
|
||
|
|
</fieldset>
|
||
|
|
|
||
|
|
{/* Deck mode toggle */}
|
||
|
|
<div className="flex items-center justify-between gap-3 py-1">
|
||
|
|
<label
|
||
|
|
htmlFor={deckModeId}
|
||
|
|
className="text-sm font-medium cursor-pointer select-none"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
Deck Mode
|
||
|
|
</label>
|
||
|
|
<button
|
||
|
|
id={deckModeId}
|
||
|
|
type="button"
|
||
|
|
role="switch"
|
||
|
|
aria-checked={Boolean(deckMode)}
|
||
|
|
onClick={handleDeckModeToggle}
|
||
|
|
className="relative inline-flex h-7 w-12 shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
|
|
style={{
|
||
|
|
backgroundColor: deckMode ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
'--tw-ring-offset-color': 'transparent',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
aria-hidden="true"
|
||
|
|
className="pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200"
|
||
|
|
style={{
|
||
|
|
backgroundColor: '#ffffff',
|
||
|
|
transform: deckMode ? 'translate(22px, 4px)' : 'translate(4px, 4px)',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{deckMode && (
|
||
|
|
<fieldset className="pl-1">
|
||
|
|
<legend
|
||
|
|
id={deckSizeId}
|
||
|
|
className="text-sm font-medium mb-2"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
Deck Size
|
||
|
|
</legend>
|
||
|
|
<ToggleGroup
|
||
|
|
options={DECK_SIZES}
|
||
|
|
value={deckMode.targetSize}
|
||
|
|
onChange={handleDeckSizeChange}
|
||
|
|
name="Deck size"
|
||
|
|
/>
|
||
|
|
</fieldset>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Start scanning CTA */}
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
size="lg"
|
||
|
|
onClick={onStartScanning}
|
||
|
|
className="w-full"
|
||
|
|
>
|
||
|
|
Start Scanning
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{/* Scan history */}
|
||
|
|
<ScanHistoryList history={scanHistory} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|