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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
92 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|