From 132e376b9f0845d0a221ffdd40a30b7b4a6518b7 Mon Sep 17 00:00:00 2001 From: eewing Date: Thu, 19 Feb 2026 11:52:23 -0600 Subject: [PATCH] Implement camera functionality in ReceiptForm and +page components for capturing photos directly. Refactor image upload handling to support both camera and gallery options, enhancing user experience. Update styles for improved accessibility and feedback during image processing. --- src/routes/receipts/ReceiptForm.svelte | 119 ++++++++++++++++++++++--- src/routes/receipts/[id]/+page.svelte | 102 ++++++++++++++++++++- 2 files changed, 207 insertions(+), 14 deletions(-) diff --git a/src/routes/receipts/ReceiptForm.svelte b/src/routes/receipts/ReceiptForm.svelte index b19901a0..d8d2e016 100644 --- a/src/routes/receipts/ReceiptForm.svelte +++ b/src/routes/receipts/ReceiptForm.svelte @@ -7,8 +7,8 @@ const TYPE_OPTIONS = ["Purchase", "Gas"] as const; - const SCAN_BUTTON_CLASS = - "border-input bg-background hover:bg-accent hover:text-accent-foreground flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors"; + const BUTTON_OUTLINE_CLASS = + "border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors"; let { action, @@ -24,9 +24,69 @@ defaultType?: string; } = $props(); + let imageInputEl = $state(null); let imageFileName = $state(null); let imageConverting = $state(false); + let showCamera = $state(false); + let cameraError = $state(null); + let videoEl = $state(undefined); + let stream: MediaStream | null = null; + + async function startCamera() { + cameraError = null; + showCamera = true; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, + }); + } catch (e) { + cameraError = e instanceof Error ? e.message : "Could not access camera."; + } + } + + $effect(() => { + if (!showCamera || !stream || !videoEl) return; + videoEl.srcObject = stream; + videoEl.play().catch(() => {}); + return () => { + if (videoEl?.srcObject) videoEl.srcObject = null; + }; + }); + + function stopCamera() { + showCamera = false; + cameraError = null; + if (stream) { + stream.getTracks().forEach((t) => t.stop()); + stream = null; + } + if (videoEl?.srcObject) videoEl.srcObject = null; + } + + function capturePhoto() { + if (!videoEl || videoEl.readyState < 2 || !imageInputEl) return; + const canvas = document.createElement("canvas"); + canvas.width = videoEl.videoWidth; + canvas.height = videoEl.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(videoEl, 0, 0); + canvas.toBlob( + (blob) => { + if (!blob) return; + const file = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() }); + const dt = new DataTransfer(); + dt.items.add(file); + imageInputEl.files = dt.files; + imageFileName = file.name; + stopCamera(); + }, + "image/webp", + 0.85 + ); + } + async function onImageChange(e: Event) { const input = e.currentTarget as HTMLInputElement; const file = input.files?.[0]; @@ -34,9 +94,12 @@ imageFileName = null; return; } + if (file.type === "image/webp") { + imageFileName = file.name; + return; + } imageConverting = true; imageFileName = null; - // Let the UI paint "Processing image…" before we block the main thread await new Promise((r) => setTimeout(r, 0)); try { const webpFile = await convertImageToWebP(file); @@ -83,19 +146,19 @@
{#if imageConverting}
@@ -103,20 +166,56 @@ Processing image…
{:else} - +
+ + +
{/if} {#if imageConverting}

Converting to WebP…

{:else if imageFileName}

Selected: {imageFileName}

{:else} -

Optional. Images are converted to WebP before upload.

+

Optional. Take a photo (saved as WebP) or choose from gallery.

{/if}
+ {#if showCamera} + + {/if} + {#if error}

{error}

{/if} diff --git a/src/routes/receipts/[id]/+page.svelte b/src/routes/receipts/[id]/+page.svelte index 0a949fb0..b5999a8d 100644 --- a/src/routes/receipts/[id]/+page.svelte +++ b/src/routes/receipts/[id]/+page.svelte @@ -125,6 +125,11 @@ let imageError = $state(false); let imageConverting = $state(false); + let showCamera = $state(false); + let cameraError = $state(null); + let videoEl = $state(undefined); + let stream: MediaStream | null = null; + /** On small screens image is collapsed by default; on lg+ expanded. */ let imageOpen = $state(false); let imageOpenInitialized = $state(false); @@ -135,6 +140,57 @@ imageOpen = mq.matches; }); + async function startCamera() { + cameraError = null; + showCamera = true; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, + }); + } catch (e) { + cameraError = e instanceof Error ? e.message : "Could not access camera."; + } + } + + $effect(() => { + if (!showCamera || !stream || !videoEl) return; + videoEl.srcObject = stream; + videoEl.play().catch(() => {}); + return () => { + if (videoEl?.srcObject) videoEl.srcObject = null; + }; + }); + + function stopCamera() { + showCamera = false; + cameraError = null; + if (stream) { + stream.getTracks().forEach((t) => t.stop()); + stream = null; + } + if (videoEl?.srcObject) videoEl.srcObject = null; + } + + function capturePhoto() { + if (!videoEl || videoEl.readyState < 2) return; + const canvas = document.createElement("canvas"); + canvas.width = videoEl.videoWidth; + canvas.height = videoEl.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(videoEl, 0, 0); + canvas.toBlob( + (blob) => { + if (!blob) return; + selectedImageFile = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() }); + imageFileName = "receipt.webp"; + stopCamera(); + }, + "image/webp", + 0.85 + ); + } + async function onImageChange() { const input = imageInputEl; const file = input?.files?.[0] ?? null; @@ -143,6 +199,11 @@ imageFileName = null; return; } + if (file.type === "image/webp") { + selectedImageFile = file; + imageFileName = file.name; + return; + } imageConverting = true; selectedImageFile = null; imageFileName = null; @@ -172,6 +233,37 @@
+ {#if showCamera} + + {/if} +
Back

Receipt

@@ -242,18 +334,17 @@ class="flex flex-col gap-4" > -
+
{#if imageConverting}
Processing image…
{:else} +