Files
nexarch/web/dms-upload/components/CameraCapture.tsx
rootandClaude Sonnet 5 0097c8cf2b
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
INT-19: mobile erfassung mobilfreundliche weboberflaeche
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:06:19 +00:00

92 lines
3.1 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { captureFrame, type CapturedPage } from "../lib/mobileCapture";
export interface CameraCaptureProps {
onSubmit: (pages: CapturedPage[]) => void;
submitting: boolean;
}
// Mobilfreundliche Kamera-Erfassung (INT-19): Live-Vorschau, erneute Aufnahme
// moeglich, mehrere Seiten vor dem Absenden sammelbar (Akzeptanzkriterium 1).
export function CameraCapture({ onSubmit, submitting }: CameraCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [pages, setPages] = useState<CapturedPage[]>([]);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
navigator.mediaDevices
?.getUserMedia({ video: { facingMode: "environment" } })
.then((stream) => {
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) videoRef.current.srcObject = stream;
setReady(true);
})
.catch(() => setError("Kamera konnte nicht geoeffnet werden. Bitte Zugriff erlauben."));
return () => {
cancelled = true;
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
const handleCapture = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
try {
const page = await captureFrame(videoRef.current, canvasRef.current);
setPages((prev) => [...prev, page]);
} catch {
setError("Aufnahme fehlgeschlagen, bitte erneut versuchen.");
}
}, []);
const handleRemove = useCallback((id: string) => {
setPages((prev) => {
const page = prev.find((p) => p.id === id);
if (page) URL.revokeObjectURL(page.previewUrl);
return prev.filter((p) => p.id !== id);
});
}, []);
const handleSubmit = useCallback(() => {
if (pages.length === 0) return;
onSubmit(pages);
}, [pages, onSubmit]);
return (
<div>
{error && <p role="alert">{error}</p>}
<video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", maxWidth: 480 }} aria-label="Kamera-Vorschau" />
<canvas ref={canvasRef} style={{ display: "none" }} />
<div>
<button type="button" onClick={handleCapture} disabled={!ready || submitting}>
Seite aufnehmen
</button>
</div>
{pages.length > 0 && (
<ul aria-label="Aufgenommene Seiten">
{pages.map((page, index) => (
<li key={page.id}>
<img src={page.previewUrl} alt={`Seite ${index + 1}`} width={80} />
<button type="button" onClick={() => handleRemove(page.id)} disabled={submitting}>
Seite {index + 1} entfernen
</button>
</li>
))}
</ul>
)}
<button type="button" onClick={handleSubmit} disabled={pages.length === 0 || submitting}>
{submitting ? "Wird gesendet …" : `${pages.length} Seite(n) absenden`}
</button>
</div>
);
}