Files
seedpgp-web/src/components/QRScanner.tsx
LC mac 7c4fc1460c feat: transform UI to dark cyberpunk theme
- 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
2026-02-08 22:27:41 +08:00

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>
);
}