mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
- Add QRScanner component with camera and image upload - Add QR code download button with auto-naming (SeedPGP_DATE_TIME.png) - Split state for backup/restore (separate public/private keys and passwords) - Improve QR generation settings (margin: 4, errorCorrection: M) - Fix Safari camera permissions and Continuity Camera support - Add React timing fix for Html5Qrcode initialization Features: - Camera scanning with live preview - Image file upload scanning - Automatic SEEDPGP1 validation - User-friendly error messages - 512x512px high-quality QR generation
225 lines
9.4 KiB
TypeScript
225 lines
9.4 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
|
import { Html5Qrcode } from 'html5-qrcode';
|
|
|
|
interface QRScannerProps {
|
|
onScanSuccess: (scannedText: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
|
const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
|
|
const [scanning, setScanning] = useState(false);
|
|
const [error, setError] = useState<string>('');
|
|
const [success, setSuccess] = useState(false);
|
|
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const startCamera = async () => {
|
|
setError('');
|
|
setScanMode('camera');
|
|
setScanning(true);
|
|
|
|
// Wait for DOM to render the #qr-reader div
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
try {
|
|
// Check if we're on HTTPS or localhost
|
|
if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
|
|
throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
|
|
}
|
|
|
|
const html5QrCode = new Html5Qrcode('qr-reader');
|
|
html5QrCodeRef.current = html5QrCode;
|
|
|
|
await html5QrCode.start(
|
|
{ facingMode: 'environment' },
|
|
{
|
|
fps: 10,
|
|
qrbox: { width: 250, height: 250 },
|
|
aspectRatio: 1.0,
|
|
},
|
|
(decodedText) => {
|
|
if (decodedText.startsWith('SEEDPGP1:')) {
|
|
setSuccess(true);
|
|
onScanSuccess(decodedText);
|
|
stopCamera();
|
|
} else {
|
|
setError('QR code found, but not a valid SEEDPGP1 frame');
|
|
}
|
|
},
|
|
() => {
|
|
// Ignore frequent scanning errors
|
|
}
|
|
);
|
|
} catch (err: any) {
|
|
console.error('Camera error:', err);
|
|
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
|
|
setScanning(false);
|
|
setScanMode(null);
|
|
}
|
|
};
|
|
|
|
|
|
const stopCamera = async () => {
|
|
if (html5QrCodeRef.current) {
|
|
try {
|
|
await html5QrCodeRef.current.stop();
|
|
html5QrCodeRef.current.clear();
|
|
} catch (err) {
|
|
console.error('Error stopping camera:', err);
|
|
}
|
|
html5QrCodeRef.current = null;
|
|
}
|
|
setScanning(false);
|
|
setScanMode(null);
|
|
};
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setError('');
|
|
setScanMode('file');
|
|
setScanning(true);
|
|
|
|
try {
|
|
const html5QrCode = new Html5Qrcode('qr-reader-file');
|
|
|
|
// Try scanning with verbose mode
|
|
const decodedText = await html5QrCode.scanFile(file, true);
|
|
|
|
if (decodedText.startsWith('SEEDPGP1:')) {
|
|
setSuccess(true);
|
|
onScanSuccess(decodedText);
|
|
html5QrCode.clear();
|
|
} else {
|
|
setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('File scan error:', err);
|
|
|
|
// Provide helpful error messages
|
|
if (err.message?.includes('No MultiFormat')) {
|
|
setError('Could not detect QR code in image. Try: 1) Taking a clearer photo, 2) Ensuring good lighting, 3) Screenshot from the Backup tab');
|
|
} else {
|
|
setError(`Scan failed: ${err.message || 'Unknown error'}`);
|
|
}
|
|
} finally {
|
|
setScanning(false);
|
|
// Reset file input so same file can be selected again
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleClose = async () => {
|
|
await stopCamera();
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
|
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Camera size={20} />
|
|
<h2 className="font-bold text-lg">Scan QR Code</h2>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-4">
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg flex gap-2 text-red-800 text-xs leading-relaxed">
|
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
|
<p>{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Display */}
|
|
{success && (
|
|
<div className="p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg flex gap-2 text-green-800 text-sm">
|
|
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
|
|
<p>QR code scanned successfully!</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mode Selection */}
|
|
{!scanMode && (
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={startCamera}
|
|
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
|
|
>
|
|
<Camera size={20} />
|
|
Use Camera
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="w-full py-4 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-slate-800 hover:to-slate-900 transition-all shadow-lg"
|
|
>
|
|
<Upload size={20} />
|
|
Upload Image
|
|
</button>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Info Box */}
|
|
<div className="pt-4 border-t border-slate-200">
|
|
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
|
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
|
|
<div>
|
|
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
|
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Camera View */}
|
|
{scanMode === 'camera' && scanning && (
|
|
<div className="space-y-3">
|
|
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
|
|
<button
|
|
onClick={stopCamera}
|
|
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
|
|
>
|
|
Stop Camera
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* File Processing View */}
|
|
{scanMode === 'file' && scanning && (
|
|
<div className="py-8 text-center">
|
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
|
|
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hidden div for file scanning */}
|
|
<div id="qr-reader-file" className="hidden"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|