refactor(scanner): extract disambiguation dialog and upload helpers. (#67)
Brief 1 of god-component-split: move ScanDisambiguationDialog and scan-capture-upload lib out of CameraScanner (~90 lines) without behavior changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ad0e254324
commit
071a3dca21
3 changed files with 124 additions and 99 deletions
|
|
@ -1,34 +1,6 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External card image URLs in disambiguation UI; next/image migration is out of scope. */
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
import { rateLimitCooldownUntil, uploadScanCapture } from '../lib/scan-capture-upload.js';
|
||||||
|
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
||||||
function rateLimitCooldownUntil(ms) {
|
|
||||||
return Date.now() + ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadScanCapture(imageData) {
|
|
||||||
const response = await fetch('/api/scan/upload-image', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ imageData }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
console.warn('Scan image upload rate-limited');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errBody = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errBody.error || 'Failed to upload scan image');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
return result.url || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CameraScanner({ onCardScanned, onError }) {
|
export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
|
@ -52,7 +24,6 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const activeVerificationRef = useRef(0);
|
const activeVerificationRef = useRef(0);
|
||||||
const lastErrorAtRef = useRef(0);
|
const lastErrorAtRef = useRef(0);
|
||||||
const disambiguationRefineRef = useRef(null);
|
const disambiguationRefineRef = useRef(null);
|
||||||
const disambiguationDialogRef = useFocusTrap(Boolean(disambiguation));
|
|
||||||
|
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
@ -1059,74 +1030,16 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{disambiguation && (
|
<ScanDisambiguationDialog
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
disambiguation={disambiguation}
|
||||||
<div
|
submittingReview={submittingReview}
|
||||||
ref={disambiguationDialogRef}
|
onPick={handleDisambiguationPick}
|
||||||
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
|
onNotInCatalog={handleNotInCatalog}
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
onCancel={() => {
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="disambiguation-title"
|
|
||||||
>
|
|
||||||
<h3 id="disambiguation-title" className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Which card is this?
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
|
||||||
</p>
|
|
||||||
{disambiguation.visionHint && (
|
|
||||||
<p className="text-xs mb-3 px-2 py-1 rounded" style={{ color: 'var(--accent-ember)', backgroundColor: 'var(--bg-tertiary)' }}>
|
|
||||||
Vision detected set: {disambiguation.visionHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{disambiguation.candidates.map((candidate) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={candidate.id}
|
|
||||||
onClick={() => handleDisambiguationPick(candidate)}
|
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-lg border text-left hover:opacity-90"
|
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}
|
|
||||||
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
|
|
||||||
>
|
|
||||||
{candidate.image_url ? (
|
|
||||||
<img src={candidate.image_url} alt="" className="w-12 h-16 object-cover rounded" />
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-16 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--bg-secondary)' }}>🃏</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>{candidate.name}</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{[candidate.set_name, candidate.set_code, candidate.card_number].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNotInCatalog}
|
|
||||||
disabled={submittingReview}
|
|
||||||
className="mt-4 w-full py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
|
||||||
>
|
|
||||||
{submittingReview ? 'Submitting…' : "My card isn't listed — send for review"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setDisambiguation(null);
|
setDisambiguation(null);
|
||||||
disambiguationRefineRef.current = null;
|
disambiguationRefineRef.current = null;
|
||||||
}}
|
}}
|
||||||
className="mt-2 w-full py-2 rounded-lg border text-sm"
|
/>
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
83
components/ScanDisambiguationDialog.js
Normal file
83
components/ScanDisambiguationDialog.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element -- external Scryfall/card CDN URLs */
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal for picking among multiple catalog matches after a scan.
|
||||||
|
*/
|
||||||
|
export default function ScanDisambiguationDialog({
|
||||||
|
disambiguation,
|
||||||
|
submittingReview,
|
||||||
|
onPick,
|
||||||
|
onNotInCatalog,
|
||||||
|
onCancel,
|
||||||
|
}) {
|
||||||
|
const dialogRef = useFocusTrap(Boolean(disambiguation));
|
||||||
|
|
||||||
|
if (!disambiguation) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="disambiguation-title"
|
||||||
|
>
|
||||||
|
<h3 id="disambiguation-title" className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Which card is this?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
||||||
|
</p>
|
||||||
|
{disambiguation.visionHint && (
|
||||||
|
<p className="text-xs mb-3 px-2 py-1 rounded" style={{ color: 'var(--accent-ember)', backgroundColor: 'var(--bg-tertiary)' }}>
|
||||||
|
Vision detected set: {disambiguation.visionHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disambiguation.candidates.map((candidate) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={candidate.id}
|
||||||
|
onClick={() => onPick(candidate)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg border text-left hover:opacity-90"
|
||||||
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
|
||||||
|
>
|
||||||
|
{candidate.image_url ? (
|
||||||
|
<img src={candidate.image_url} alt="" className="w-12 h-16 object-cover rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-16 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--bg-secondary)' }}>🃏</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>{candidate.name}</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{[candidate.set_name, candidate.set_code, candidate.card_number].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNotInCatalog}
|
||||||
|
disabled={submittingReview}
|
||||||
|
className="mt-4 w-full py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
||||||
|
>
|
||||||
|
{submittingReview ? 'Submitting…' : "My card isn't listed — send for review"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="mt-2 w-full py-2 rounded-lg border text-sm"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
lib/scan-capture-upload.js
Normal file
29
lib/scan-capture-upload.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/** Cooldown timestamp for rate-limit backoff (ms from now). */
|
||||||
|
export function rateLimitCooldownUntil(ms) {
|
||||||
|
return Date.now() + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a base64 scan capture to Blob storage; returns public URL or null on 429. */
|
||||||
|
export async function uploadScanCapture(imageData) {
|
||||||
|
const response = await fetch('/api/scan/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ imageData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
console.warn('Scan image upload rate-limited');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errBody.error || 'Failed to upload scan image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.url || null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue