feat(03-03): CameraCapture modal with getUserMedia

- Full-screen camera modal with live preview via getUserMedia
- Photo capture using canvas.toBlob (JPEG at 0.9 quality)
- Preview captured photo with retake/confirm flow
- Camera stream properly stopped on close and after capture
- Switch camera button for front/back camera toggle
- Upload integration via ?/uploadImage form action

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Richter
2026-01-29 15:27:51 +01:00
parent a35b07e45e
commit 8248e0cd91

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
interface Props {
entryId: string;
onClose: () => void;
onCapture?: (imageId: string) => void;
}
let { entryId, onClose, onCapture }: Props = $props();
// Stream and capture state
let stream: MediaStream | null = $state(null);
let capturedBlob: Blob | null = $state(null);
let capturedPreviewUrl: string | null = $state(null);
let error: string | null = $state(null);
let isUploading = $state(false);
let facingMode: 'environment' | 'user' = $state('environment');
// Element refs
let videoElement: HTMLVideoElement;
let canvasElement: HTMLCanvasElement;
// Feature detection
const hasCameraSupport =
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext;
async function startCamera() {
// Check browser support
if (!hasCameraSupport) {
error = 'Camera not supported in this browser. Use the file upload option instead.';
return;
}
// Check secure context (HTTPS required except localhost)
if (!isSecureContext) {
error = 'Camera requires HTTPS connection.';
return;
}
// Stop existing stream if any
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
error = null;
// Set up timeout for camera start
const timeoutId = setTimeout(() => {
if (!stream) {
error = 'Camera took too long to start. Please try again.';
}
}, 10000);
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode,
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
clearTimeout(timeoutId);
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
}
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError') {
error = 'Camera access denied. Check browser settings to allow camera.';
} else if (err.name === 'NotFoundError') {
error = 'No camera found on this device. Use file upload instead.';
} else {
error = 'Could not access camera: ' + err.message;
}
} else {
error = 'Could not access camera.';
}
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
if (videoElement) {
videoElement.srcObject = null;
}
}
async function capturePhoto() {
if (!videoElement || !canvasElement) return;
const ctx = canvasElement.getContext('2d');
if (!ctx) return;
// Set canvas dimensions to match video
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
// Draw current video frame to canvas
ctx.drawImage(videoElement, 0, 0);
// Convert canvas to blob
return new Promise<void>((resolve) => {
canvasElement.toBlob(
(blob) => {
if (blob) {
capturedBlob = blob;
capturedPreviewUrl = URL.createObjectURL(blob);
// Stop camera to save battery while reviewing
stopCamera();
}
resolve();
},
'image/jpeg',
0.9
);
});
}
async function retake() {
// Revoke preview URL to free memory
if (capturedPreviewUrl) {
URL.revokeObjectURL(capturedPreviewUrl);
}
capturedBlob = null;
capturedPreviewUrl = null;
// Restart camera
await startCamera();
}
async function confirmAndUpload() {
if (!capturedBlob) return;
isUploading = true;
try {
const formData = new FormData();
formData.append('image', capturedBlob, 'camera-capture.jpg');
formData.append('entryId', entryId);
const response = await fetch('?/uploadImage', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.type === 'success' && result.data?.imageId) {
onCapture?.(result.data.imageId);
await invalidateAll();
handleClose();
} else {
error = result.data?.error || 'Failed to upload photo.';
isUploading = false;
}
} catch {
error = 'Failed to upload photo. Please try again.';
isUploading = false;
}
}
function switchCamera() {
facingMode = facingMode === 'environment' ? 'user' : 'environment';
startCamera();
}
function handleClose() {
stopCamera();
// Revoke any preview URL
if (capturedPreviewUrl) {
URL.revokeObjectURL(capturedPreviewUrl);
capturedPreviewUrl = null;
}
capturedBlob = null;
onClose();
}
// Start camera on mount
$effect(() => {
startCamera();
// Cleanup on unmount
return () => {
stopCamera();
if (capturedPreviewUrl) {
URL.revokeObjectURL(capturedPreviewUrl);
}
};
});
</script>
<div class="fixed inset-0 z-50 bg-black flex flex-col">
<!-- Header with close and switch camera buttons -->
<div class="flex justify-between items-center p-4">
<button
type="button"
onclick={handleClose}
class="w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center"
aria-label="Close camera"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if !error && !capturedPreviewUrl}
<button
type="button"
onclick={switchCamera}
class="w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center"
aria-label="Switch camera"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 flex items-center justify-center overflow-hidden">
{#if error}
<div class="text-center p-6">
<p class="text-white text-lg mb-4">{error}</p>
<button
type="button"
onclick={handleClose}
class="px-6 py-3 bg-white text-black rounded-lg font-medium"
>
Close
</button>
</div>
{:else if capturedPreviewUrl}
<!-- Preview captured photo -->
<img
src={capturedPreviewUrl}
alt="Captured photo preview"
class="max-h-full max-w-full object-contain"
/>
{:else}
<!-- Live camera view -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="max-h-full max-w-full object-contain"
></video>
{/if}
</div>
<!-- Hidden canvas for capture -->
<canvas bind:this={canvasElement} class="hidden"></canvas>
<!-- Action buttons -->
<div class="p-4 pb-8 flex justify-center gap-4">
{#if capturedPreviewUrl}
<!-- Retake / Confirm buttons -->
<button
type="button"
onclick={retake}
disabled={isUploading}
class="px-6 py-3 bg-white/20 text-white rounded-lg font-medium disabled:opacity-50"
>
Retake
</button>
<button
type="button"
onclick={confirmAndUpload}
disabled={isUploading}
class="px-6 py-3 bg-white text-black rounded-lg font-medium disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Use Photo'}
</button>
{:else if !error}
<!-- Capture button - large circle -->
<button
type="button"
onclick={capturePhoto}
class="w-16 h-16 rounded-full bg-white border-4 border-white/50 flex items-center justify-center active:scale-95 transition-transform"
aria-label="Capture photo"
>
<span class="w-12 h-12 rounded-full bg-white"></span>
</button>
{/if}
</div>
</div>