mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
- Eliminate all white/light boxes and backgrounds - Fix drag-drop zone with neon cyberpunk colors (#00f0ff, #ff006e, #16213e) - Fix restored mnemonic display with matrix green (#39ff14) - Fix security options panel with dark gradient - Fix all remaining slate-700/slate-800 labels to cyberpunk neon - Fix info banners and text colors - Update badge components with cyberpunk color scheme - Apply consistent dark theme across all components
175 lines
7.6 KiB
TypeScript
175 lines
7.6 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { Camera, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
|
import jsQR from 'jsqr';
|
|
|
|
interface QRScannerProps {
|
|
onScanSuccess: (data: string | Uint8Array) => void;
|
|
onClose: () => void;
|
|
onError?: (error: string) => void;
|
|
}
|
|
|
|
export default function QRScanner({ onScanSuccess, onClose, onError }: QRScannerProps) {
|
|
const [internalError, setInternalError] = useState<string>('');
|
|
const [success, setSuccess] = useState(false);
|
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
let stream: MediaStream | null = null;
|
|
let scanInterval: number | null = null;
|
|
let isCancelled = false;
|
|
|
|
const stopScanning = () => {
|
|
if (scanInterval) clearInterval(scanInterval);
|
|
if (stream) stream.getTracks().forEach(track => track.stop());
|
|
};
|
|
|
|
const startScanning = async () => {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'environment' }
|
|
});
|
|
|
|
if (isCancelled) return stopScanning();
|
|
|
|
setHasPermission(true);
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
|
|
if (video && canvas) {
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
|
|
if (isCancelled) return stopScanning();
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
setInternalError('Canvas context not available');
|
|
return stopScanning();
|
|
}
|
|
|
|
scanInterval = window.setInterval(() => {
|
|
if (isCancelled || !video || video.paused || video.ended) return;
|
|
if (!video.videoWidth || !video.videoHeight) return;
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
|
inversionAttempts: 'dontInvert'
|
|
});
|
|
|
|
if (code) {
|
|
isCancelled = true;
|
|
stopScanning();
|
|
setSuccess(true);
|
|
|
|
// jsQR gives us raw bytes!
|
|
const rawBytes = code.binaryData;
|
|
|
|
// Detect binary (16 or 32 bytes with non-printable chars)
|
|
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
|
|
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
|
|
|
|
if (isBinary) {
|
|
onScanSuccess(new Uint8Array(rawBytes));
|
|
} else {
|
|
// Text QR - use the text property
|
|
onScanSuccess(code.data);
|
|
}
|
|
|
|
setTimeout(() => onClose(), 1000);
|
|
}
|
|
}, 300);
|
|
}
|
|
} catch (err: any) {
|
|
// IMPORTANT: Check for null/undefined err object first.
|
|
if (!err) {
|
|
console.error('Caught a null or undefined error inside QRScanner.');
|
|
return; // Exit if error is falsy
|
|
}
|
|
|
|
if (isCancelled || err.name === 'AbortError') {
|
|
console.log('Camera operation was cancelled or aborted, which is expected on unmount.');
|
|
return; // Ignore abort errors, they are expected on cleanup
|
|
}
|
|
|
|
console.error('Camera error:', err);
|
|
setHasPermission(false);
|
|
|
|
let errorMsg = 'An unknown camera error occurred.';
|
|
if (err.name === 'NotAllowedError') {
|
|
errorMsg = 'Camera access was denied. Please grant permission in your browser settings.';
|
|
} else if (err.name === 'NotFoundError') {
|
|
errorMsg = 'No camera found on this device.';
|
|
} else if (err.name === 'NotReadableError') {
|
|
errorMsg = 'Cannot access the camera. It may be in use by another application or browser tab.';
|
|
} else if (err.name === 'OverconstrainedError') {
|
|
errorMsg = 'The camera does not meet the required constraints.';
|
|
} else if (err instanceof Error) {
|
|
errorMsg = `Camera error: ${err.message}`;
|
|
}
|
|
|
|
setInternalError(errorMsg);
|
|
onError?.(errorMsg);
|
|
}
|
|
};
|
|
|
|
startScanning();
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
stopScanning();
|
|
};
|
|
}, [onScanSuccess, onClose, onError]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
|
|
<div className="bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 p-6 max-w-md w-full mx-4 shadow-[0_0_40px_rgba(0,240,255,0.3)]">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-[#00f0ff] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
<Camera size={20} />
|
|
Scan QR Code
|
|
</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-[#1a1a2e] rounded-lg transition-colors border-2 border-[#00f0ff]/30">
|
|
<X size={20} className="text-[#6ef3f7]" />
|
|
</button>
|
|
</div>
|
|
|
|
{internalError && (
|
|
<div className="mb-4 p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 rounded-lg flex items-start gap-2 text-[#ff006e] text-sm">
|
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
|
<span>{internalError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{success && (
|
|
<div className="mb-4 p-3 bg-[#39ff14]/10 border-2 border-[#39ff14]/30 rounded-lg flex items-center gap-2 text-[#39ff14] text-sm">
|
|
<CheckCircle2 size={16} />
|
|
<span>QR Code detected!</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative bg-black rounded-lg overflow-hidden border-2 border-[#00f0ff]/30">
|
|
<video ref={videoRef} className="w-full h-64 object-cover" playsInline muted />
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
</div>
|
|
|
|
{!hasPermission && !internalError && (
|
|
<p className="text-sm text-[#6ef3f7] mt-3 text-center">Requesting camera access...</p>
|
|
)}
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full mt-4 py-2 bg-[#1a1a2e] hover:bg-[#16213e] rounded-lg text-[#00f0ff] font-medium transition-all border-2 border-[#00f0ff]/50 hover:shadow-[0_0_15px_rgba(0,240,255,0.3)]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|