| // src/components/lab/BarcodeScannerModal.tsx | |
| import { useZxing } from "react-zxing"; | |
| import { X, ScanLine, Camera, CameraOff } from "lucide-react"; | |
| import { useState, useEffect, type RefCallback } from "react"; | |
| import { toast } from "react-toastify"; | |
| interface BarcodeScannerModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onScan: (barcode: string) => void; | |
| } | |
| export function BarcodeScannerModal({ isOpen, onClose, onScan }: BarcodeScannerModalProps) { | |
| const [error, setError] = useState<string | null>(null); | |
| const [lastScanned, setLastScanned] = useState<string | null>(null); | |
| const [scanCount, setScanCount] = useState<Record<string, number>>({}); | |
| const { ref } = useZxing({ | |
| onDecodeResult(result) { | |
| const code = result.getText(); | |
| // Debounce: require 3 scans of the same code within 1 second | |
| const now = Date.now(); | |
| const counts = { ...scanCount, [code]: (scanCount[code] || 0) + 1 }; | |
| setScanCount(counts); | |
| setLastScanned(code); | |
| if (counts[code] >= 3) { | |
| onScan(code); | |
| setScanCount({}); | |
| setLastScanned(null); | |
| } | |
| }, | |
| onError(err) { | |
| if (err instanceof DOMException && err.name === "NotAllowedError") { | |
| setError("Camera access denied. Please allow camera permissions."); | |
| } | |
| }, | |
| constraints: { video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } } }, | |
| }); | |
| const videoRef = ref as unknown as RefCallback<HTMLVideoElement>; | |
| // Clear stale scans after 1.5s | |
| useEffect(() => { | |
| const timer = setInterval(() => { setScanCount({}); setLastScanned(null); }, 1500); | |
| return () => clearInterval(timer); | |
| }, []); | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50" onClick={onClose}> | |
| <div className="relative w-full max-w-lg mx-4 bg-gray-900 rounded-2xl overflow-hidden shadow-2xl" onClick={e => e.stopPropagation()}> | |
| {/* Header */} | |
| <div className="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/60 to-transparent"> | |
| <div className="flex items-center gap-2 text-white"> | |
| <ScanLine className="w-5 h-5" /> | |
| <span className="font-semibold text-sm">Scan Barcode</span> | |
| </div> | |
| <button onClick={onClose} className="p-1.5 bg-white/20 rounded-full hover:bg-white/30"><X className="w-4 h-4 text-white"/></button> | |
| </div> | |
| {/* Video */} | |
| <video ref={videoRef} className="w-full aspect-[4/3] object-cover" autoPlay playsInline muted /> | |
| {/* Scan line animation */} | |
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |
| <div className="w-3/4 h-0.5 bg-red-500/60 animate-pulse shadow-[0_0_20px_rgba(239,68,68,0.5)]" /> | |
| </div> | |
| {/* Last scanned */} | |
| {lastScanned && ( | |
| <div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-white/90 dark:bg-gray-800/90 rounded-full px-4 py-1.5 text-sm font-mono"> | |
| {lastScanned} | |
| <span className="ml-2 text-xs text-gray-500">({scanCount[lastScanned] || 0}/3)</span> | |
| </div> | |
| )} | |
| {/* Error */} | |
| {error && ( | |
| <div className="absolute bottom-4 left-4 right-4 bg-red-500/90 text-white text-sm p-3 rounded-xl flex items-center gap-2"> | |
| <CameraOff className="w-4 h-4 shrink-0" /> {error} | |
| </div> | |
| )} | |
| {/* Bottom controls */} | |
| <div className="absolute bottom-4 left-1/2 -translate-x-1/2"> | |
| <button onClick={onClose} className="bg-white/20 hover:bg-white/30 text-white px-6 py-2 rounded-full text-sm font-medium backdrop-blur"> | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 3.83 kB
- Xet hash:
- 70c72f7674c348945601583f7d161c7eb587f6a52d4e0e3d4966885311fdd279
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.