Files
tfm_ainventory/frontend/components/Scanner.tsx
Daniel Bedeleanu 91e986b32c refactor(ux): simplify jargon and improve feature discoverability
Addressed P3 design opportunities from critique:

1. Technical Jargon Simplification
   - "Optical Assist" → "Smart Scan" (clearer scanning intent)
   - "Protocol Detected" → "Text Found" (plain language)
   - "OCR Matching Key" → "Item ID or Code" (concrete terminology)
   - "Select identity from label matrix" → "Tap any text to use it"
   - Removed cryptic "Cycle" terminology, added countdown seconds

2. Feature Discoverability
   - Added helpful descriptions to Admin manager sections
   - Scanner status message now hints at zoom and tap features
   - Concrete examples in input placeholders

3. Help & Documentation
   - User Management: "Create accounts and control access"
   - Category Groups: "Organize items by type or location"
   - Sign Out: clearer procedure description

Build: passes with zero errors
2026-04-17 11:10:10 +03:00

359 lines
14 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import { RefreshCw, XCircle, Search } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface ScannerProps {
onScanSuccess: (decodedText: string) => void;
onOCRMatch?: (text: string) => void;
paused?: boolean;
}
export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerProps) {
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
const [isStarted, setIsStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ocrProcessing, setOcrProcessing] = useState(false);
const [countdown, setCountdown] = useState(4);
const [zoom, setZoom] = useState(1);
const [maxZoom, setMaxZoom] = useState(1);
const [hasZoom, setHasZoom] = useState(false);
const [detectedWords, setDetectedWords] = useState<{ text: string, bbox: { x0: number, y0: number, x1: number, y1: number } }[]>([]);
const [isSelecting, setIsSelecting] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const isBusy = useRef(false);
const isTransitioning = useRef(false); // Flag to track start/stop transitions
const scannerId = "reader-container-unique";
useEffect(() => {
if (!html5QrCodeRef.current) {
html5QrCodeRef.current = new Html5Qrcode(scannerId);
}
const startScanner = async () => {
if (isBusy.current || isTransitioning.current) return;
isBusy.current = true;
isTransitioning.current = true;
try {
// If already scanning, we MUST stop it first
if (html5QrCodeRef.current?.isScanning) {
try {
await html5QrCodeRef.current.stop();
} catch (e) {
console.warn("Graceful stop failed, might be in transition:", e);
// If it's already in transition, we just return and wait for the next effect trigger
return;
}
}
const config = {
fps: 15,
qrbox: { width: 320, height: 320 },
aspectRatio: 1.0,
formatsToSupport: [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.DATA_MATRIX
],
videoConstraints: {
facingMode: "environment"
}
};
await html5QrCodeRef.current?.start(
{ facingMode: "environment" },
config,
(decodedText) => {
onScanSuccess(decodedText);
},
() => {} // Ignore frame errors
);
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
const caps = track?.getCapabilities() as any;
if (track && caps?.zoom) {
setHasZoom(true);
setMaxZoom(caps.zoom.max || 3);
}
setIsStarted(true);
setError(null);
} catch (err: any) {
const errorMsg = String(err);
if (!errorMsg.includes("is already starting") && !errorMsg.includes("already under transition")) {
console.error("Scanner failed", err);
setError(errorMsg || "Failed to access camera");
setIsStarted(false);
}
} finally {
isBusy.current = false;
isTransitioning.current = false;
}
};
if (!paused) {
startScanner();
}
return () => {
if (html5QrCodeRef.current?.isScanning) {
isTransitioning.current = true;
html5QrCodeRef.current.stop()
.then(() => {
setIsStarted(false);
})
.catch(e => console.error("Unmount stop failed", e))
.finally(() => {
isTransitioning.current = false;
});
}
};
}, [paused, onScanSuccess]);
// Automated OCR Countdown Timer
useEffect(() => {
if (!isStarted || paused || isSelecting || ocrProcessing) {
return;
}
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
handleOCR();
return 4; // Reset to 4
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isStarted, paused, isSelecting, ocrProcessing]);
const handleOCR = async () => {
if (ocrProcessing) return;
setOcrProcessing(true);
try {
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
if (!video) return;
const canvas = document.createElement('canvas');
const vWidth = video.videoWidth || 1280;
const vHeight = video.videoHeight || 720;
const cropFactor = 0.6;
const sw = vWidth * cropFactor;
const sh = vHeight * cropFactor;
const sx = (vWidth - sw) / 2;
const sy = (vHeight - sh) / 2;
canvas.width = 1200;
canvas.height = (sh / sw) * 1200;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}
// Lazy-load tesseract.js only when OCR is actually needed
const { createWorker } = await import('tesseract.js');
const workerPromise = createWorker('eng');
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 8000));
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setCapturedImage(dataUrl);
const result = await worker.recognize(dataUrl);
const data = result.data;
if (data && data.text && (!data.words || data.words.length === 0)) {
onOCRMatch?.(data.text);
await worker.terminate();
setCapturedImage(null);
return;
}
if (!data || !data.words || data.words.length === 0) {
await worker.terminate();
setCapturedImage(null);
return;
}
const words = data.words.map((w: any) => ({
text: w.text,
bbox: w.bbox
}));
setDetectedWords(words);
setIsSelecting(true);
await worker.terminate();
} catch (err) {
console.error("OCR failed", err);
} finally {
setOcrProcessing(false);
}
};
const handleWordSelect = (text: string) => {
onOCRMatch?.(text);
setIsSelecting(false);
setCapturedImage(null);
setDetectedWords([]);
setCountdown(4); // Restart countdown
};
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
return (
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
{/* Video Viewport Area */}
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-slate-950 border-2 border-slate-800/50">
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
<div className="w-[85%] h-[85%] border border-primary/30 rounded-[2rem] relative">
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
{isStarted && !paused && !isSelecting && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary to-transparent shadow-[0_0_20px_rgba(59,130,246,0.8)] animate-scan-fast" />
)}
</div>
</div>
<div id={scannerId} className="w-full h-full bg-slate-900 object-cover" />
{/* Selection UI */}
{isSelecting && capturedImage && (
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
<img src={capturedImage || undefined} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative" style={{ width: '100%', height: '100%' }}>
{detectedWords.map((w, i) => (
<button
key={i}
onClick={() => handleWordSelect(w.text)}
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
style={{
left: `${(w.bbox.x0 / 1600) * 100}%`,
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`,
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
}}
/>
))}
</div>
</div>
</div>
<div className="p-6 bg-slate-900/90 border-t border-slate-800 flex flex-col gap-4">
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-black text-white italic text-center">Text Found</p>
<p className="text-xs text-slate-500 font-black text-center">Tap any text to use it</p>
</div>
<button
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-xs transition-all active:scale-95 border border-slate-700"
>
Cancel
</button>
</div>
</div>
)}
{!isStarted && !error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm font-medium">Initializing camera...</p>
</div>
)}
{error && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
<XCircle className="w-10 h-10 text-red-500" />
<div>
<p className="font-bold text-white">Camera Error</p>
<p className="text-xs text-slate-400 mt-1">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
>
Try Again
</button>
</div>
)}
</div>
{/* External Controls Area */}
<div className="flex flex-col gap-4 bg-slate-900/40 p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
<div className="flex items-center gap-3 w-full">
{hasZoom && (
<button
onClick={async () => {
let nextZoom = 1;
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
else if (zoom < maxZoom) nextZoom = maxZoom;
else nextZoom = 1;
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
if (track) {
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
setZoom(nextZoom);
}
}}
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95 shrink-0"
>
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
<span className="text-xs text-primary font-black tracking-tighter">Zoom</span>
</button>
)}
<div className="flex-1 h-14 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
{ocrProcessing ? (
<>
<RefreshCw className="animate-spin text-primary" size={18} />
<span className="text-xs font-black text-slate-200 leading-none">Analyzing</span>
</>
) : (
<>
<Search className={cn("text-slate-600 transition-colors group-hover:text-primary", !isStarted && "opacity-20")} size={18} />
<div className="flex flex-col">
<span className="text-xs text-slate-500 font-black leading-none">Smart Scan</span>
<span className="text-xs font-black text-primary leading-tight tabular-nums">
{countdown === 0 ? "Scanning" : `${countdown}s`}
</span>
</div>
</>
)}
{/* Visual Progress Bar */}
<div
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
/>
</div>
</div>
<div className="w-full flex justify-center items-center gap-2">
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
<p className="text-xs text-slate-600 font-black">
Scanner active · Use zoom or tap scan
</p>
</div>
</div>
</div>
);
}