feat(*): send images from website
This commit is contained in:
184
frontend/src/lib/components/CameraCapture.svelte
Normal file
184
frontend/src/lib/components/CameraCapture.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
showPreview?: boolean;
|
||||
oncapture: (base64: string, mediaType: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { showPreview = true, oncapture, onclose }: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let capturedImage: { base64: string; mediaType: string } | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let closed = false;
|
||||
|
||||
async function findUltraWideCamera(): Promise<string | null> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter((d) => d.kind === 'videoinput');
|
||||
const ultraWide = videoDevices.find(
|
||||
(d) => d.label.toLowerCase().includes('ultra') && d.label.toLowerCase().includes('back')
|
||||
);
|
||||
return ultraWide?.deviceId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
if (closed) return;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
error = 'Camera not supported (requires HTTPS)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
audio: false
|
||||
});
|
||||
|
||||
const ultraWideId = await findUltraWideCamera();
|
||||
|
||||
if (ultraWideId) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
deviceId: { exact: ultraWideId },
|
||||
width: { ideal: 4032 },
|
||||
height: { ideal: 3024 }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
} else {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 4032 },
|
||||
height: { ideal: 3024 }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
}
|
||||
|
||||
if (videoElement && !closed) {
|
||||
videoElement.srcObject = stream;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Camera access denied';
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function capture() {
|
||||
if (!videoElement) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(videoElement, 0, 0);
|
||||
|
||||
const maxSize = 1920;
|
||||
const scale = Math.min(maxSize / canvas.width, maxSize / canvas.height, 1);
|
||||
const outCanvas = document.createElement('canvas');
|
||||
outCanvas.width = Math.round(canvas.width * scale);
|
||||
outCanvas.height = Math.round(canvas.height * scale);
|
||||
const outCtx = outCanvas.getContext('2d');
|
||||
if (!outCtx) return;
|
||||
outCtx.drawImage(canvas, 0, 0, outCanvas.width, outCanvas.height);
|
||||
|
||||
const base64 = outCanvas.toDataURL('image/jpeg', 0.65).split(',')[1];
|
||||
const mediaType = 'image/jpeg';
|
||||
|
||||
stopCamera();
|
||||
|
||||
if (showPreview) {
|
||||
capturedImage = { base64, mediaType };
|
||||
} else {
|
||||
oncapture(base64, mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
function acceptCapture() {
|
||||
if (capturedImage) {
|
||||
oncapture(capturedImage.base64, capturedImage.mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
function retake() {
|
||||
capturedImage = null;
|
||||
startCamera();
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
stopCamera();
|
||||
onclose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startCamera();
|
||||
return () => {
|
||||
closed = true;
|
||||
stopCamera();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-black" data-camera-ui>
|
||||
{#if error}
|
||||
<div class="flex flex-1 flex-col items-center justify-center p-4">
|
||||
<p class="mb-4 text-center text-sm text-red-400">{error}</p>
|
||||
<button onclick={close} class="rounded bg-neutral-700 px-4 py-2 text-sm text-white">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{:else if capturedImage}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<button class="absolute inset-0" onclick={acceptCapture}>
|
||||
<img
|
||||
src="data:{capturedImage.mediaType};base64,{capturedImage.base64}"
|
||||
alt="Captured"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-4 p-4">
|
||||
<button onclick={retake} class="flex-1 rounded bg-neutral-700 py-3 text-sm text-white">
|
||||
Retake
|
||||
</button>
|
||||
<button onclick={acceptCapture} class="flex-1 rounded bg-blue-600 py-3 text-sm text-white">
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<video bind:this={videoElement} autoplay playsinline muted class="h-full w-full object-cover"
|
||||
></video>
|
||||
<button
|
||||
onclick={close}
|
||||
class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
onclick={capture}
|
||||
aria-label="Capture photo"
|
||||
class="absolute bottom-8 left-1/2 h-16 w-16 -translate-x-1/2 rounded-full border-4 border-white bg-white/20"
|
||||
></button>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user