146 lines
3.9 KiB
TypeScript
146 lines
3.9 KiB
TypeScript
// components/ScanModal.tsx
|
||
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import Modal from '@/components/ui/Modal';
|
||
import { CameraIcon } from '@heroicons/react/24/outline';
|
||
import { BrowserMultiFormatReader, IScannerControls } from '@zxing/browser';
|
||
|
||
type ScanModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onResult: (code: string) => void;
|
||
};
|
||
|
||
export default function ScanModal({ open, onClose, onResult }: ScanModalProps) {
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
|
||
setError(null);
|
||
|
||
// Browser / Secure-Context Check
|
||
if (
|
||
typeof navigator === 'undefined' ||
|
||
!navigator.mediaDevices ||
|
||
typeof navigator.mediaDevices.getUserMedia !== 'function'
|
||
) {
|
||
setError(
|
||
'Kamera wird in diesem Kontext nicht unterstützt. Bitte die Seite über HTTPS oder localhost aufrufen.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const codeReader = new BrowserMultiFormatReader();
|
||
let controls: IScannerControls | null = null;
|
||
let stopped = false;
|
||
let rafId: number | null = null;
|
||
|
||
const startScanner = () => {
|
||
// warten, bis das <video> da ist
|
||
if (!videoRef.current) {
|
||
rafId = requestAnimationFrame(startScanner);
|
||
return;
|
||
}
|
||
|
||
codeReader
|
||
.decodeFromConstraints(
|
||
{
|
||
audio: false,
|
||
video: {
|
||
facingMode: { ideal: 'environment' }, // Rückkamera bevorzugen
|
||
},
|
||
},
|
||
videoRef.current,
|
||
(result, err, _controls) => {
|
||
if (_controls && !controls) {
|
||
controls = _controls;
|
||
}
|
||
|
||
if (result && !stopped) {
|
||
stopped = true;
|
||
if (controls) controls.stop();
|
||
onResult(result.getText());
|
||
onClose();
|
||
}
|
||
|
||
if (err && err.name !== 'NotFoundException') {
|
||
console.warn('Scanner-Fehler:', err);
|
||
}
|
||
},
|
||
)
|
||
.catch((e) => {
|
||
console.error('Error starting camera', e);
|
||
setError(
|
||
'Kamera konnte nicht geöffnet werden. Bitte Berechtigungen prüfen oder einen anderen Browser verwenden.',
|
||
);
|
||
});
|
||
};
|
||
|
||
startScanner();
|
||
|
||
return () => {
|
||
stopped = true;
|
||
|
||
if (rafId !== null) {
|
||
cancelAnimationFrame(rafId);
|
||
}
|
||
|
||
if (controls) {
|
||
controls.stop();
|
||
}
|
||
|
||
// Stream hart stoppen, falls noch aktiv
|
||
if (videoRef.current && videoRef.current.srcObject instanceof MediaStream) {
|
||
videoRef.current.srcObject.getTracks().forEach((t) => t.stop());
|
||
videoRef.current.srcObject = null;
|
||
}
|
||
|
||
// ⚠️ KEIN codeReader.reset() mehr – macht bei dir Runtime-Fehler
|
||
};
|
||
}, [open, onClose, onResult]);
|
||
|
||
const handleClose = () => onClose();
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={handleClose}
|
||
title="QR-Code scannen"
|
||
icon={<CameraIcon className="size-6" />}
|
||
tone="info"
|
||
variant="centered"
|
||
size="md"
|
||
primaryAction={{
|
||
label: 'Abbrechen',
|
||
onClick: handleClose,
|
||
variant: 'secondary',
|
||
}}
|
||
>
|
||
<div className="mt-4 space-y-3">
|
||
{error && (
|
||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||
)}
|
||
|
||
{!error && (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
Richte deine Kamera auf den QR-Code. Sobald er erkannt wird, öffnet sich das Gerätedetail.
|
||
</p>
|
||
)}
|
||
|
||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 bg-black dark:border-white/10">
|
||
<video
|
||
ref={videoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
className="block w-full h-80 object-contain"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|