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.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m29s

This commit is contained in:
eewing
2026-02-19 11:52:23 -06:00
parent c06daf4f7c
commit 132e376b9f
2 changed files with 207 additions and 14 deletions
+109 -10
View File
@@ -7,8 +7,8 @@
const TYPE_OPTIONS = ["Purchase", "Gas"] as const; const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
const SCAN_BUTTON_CLASS = const BUTTON_OUTLINE_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"; "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 { let {
action, action,
@@ -24,9 +24,69 @@
defaultType?: string; defaultType?: string;
} = $props(); } = $props();
let imageInputEl = $state<HTMLInputElement | null>(null);
let imageFileName = $state<string | null>(null); let imageFileName = $state<string | null>(null);
let imageConverting = $state(false); let imageConverting = $state(false);
let showCamera = $state(false);
let cameraError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(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) { async function onImageChange(e: Event) {
const input = e.currentTarget as HTMLInputElement; const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
@@ -34,9 +94,12 @@
imageFileName = null; imageFileName = null;
return; return;
} }
if (file.type === "image/webp") {
imageFileName = file.name;
return;
}
imageConverting = true; imageConverting = true;
imageFileName = null; imageFileName = null;
// Let the UI paint "Processing image…" before we block the main thread
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
try { try {
const webpFile = await convertImageToWebP(file); const webpFile = await convertImageToWebP(file);
@@ -83,19 +146,19 @@
<Label for="Image">Image</Label> <Label for="Image">Image</Label>
<div class="relative flex flex-col gap-2"> <div class="relative flex flex-col gap-2">
<input <input
bind:this={imageInputEl}
id="Image" id="Image"
name="Image" name="Image"
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
onchange={onImageChange} onchange={onImageChange}
class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none" class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
tabindex="-1" tabindex="-1"
aria-label="Scan receipt" aria-label="Choose image from gallery"
/> />
{#if imageConverting} {#if imageConverting}
<div <div
class="{SCAN_BUTTON_CLASS} pointer-events-none opacity-80" class="{BUTTON_OUTLINE_CLASS} w-full pointer-events-none opacity-80"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
> >
@@ -103,20 +166,56 @@
<span>Processing image…</span> <span>Processing image…</span>
</div> </div>
{:else} {:else}
<label for="Image" class={SCAN_BUTTON_CLASS}> <div class="flex flex-wrap gap-2">
Scan receipt <Button type="button" variant="outline" class="flex-1 sm:flex-none" onclick={startCamera}>
</label> Take photo
</Button>
<label for="Image" class="{BUTTON_OUTLINE_CLASS} flex-1 sm:flex-none">
Choose from gallery
</label>
</div>
{/if} {/if}
{#if imageConverting} {#if imageConverting}
<p class="text-muted-foreground text-xs">Converting to WebP…</p> <p class="text-muted-foreground text-xs">Converting to WebP…</p>
{:else if imageFileName} {:else if imageFileName}
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p> <p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
{:else} {:else}
<p class="text-muted-foreground text-xs">Optional. Images are converted to WebP before upload.</p> <p class="text-muted-foreground text-xs">Optional. Take a photo (saved as WebP) or choose from gallery.</p>
{/if} {/if}
</div> </div>
</div> </div>
{#if showCamera}
<div
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Take receipt photo"
>
<div class="flex w-full max-w-lg flex-col gap-3">
{#if cameraError}
<p class="text-destructive text-sm">{cameraError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input aspect-video w-full rounded-lg border bg-black"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
Cancel
</Button>
{#if !cameraError}
<Button type="button" class="flex-1" onclick={capturePhoto}>
Capture
</Button>
{/if}
</div>
</div>
</div>
{/if}
{#if error} {#if error}
<p class="text-destructive text-sm">{error}</p> <p class="text-destructive text-sm">{error}</p>
{/if} {/if}
+98 -4
View File
@@ -125,6 +125,11 @@
let imageError = $state(false); let imageError = $state(false);
let imageConverting = $state(false); let imageConverting = $state(false);
let showCamera = $state(false);
let cameraError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
/** On small screens image is collapsed by default; on lg+ expanded. */ /** On small screens image is collapsed by default; on lg+ expanded. */
let imageOpen = $state(false); let imageOpen = $state(false);
let imageOpenInitialized = $state(false); let imageOpenInitialized = $state(false);
@@ -135,6 +140,57 @@
imageOpen = mq.matches; 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() { async function onImageChange() {
const input = imageInputEl; const input = imageInputEl;
const file = input?.files?.[0] ?? null; const file = input?.files?.[0] ?? null;
@@ -143,6 +199,11 @@
imageFileName = null; imageFileName = null;
return; return;
} }
if (file.type === "image/webp") {
selectedImageFile = file;
imageFileName = file.name;
return;
}
imageConverting = true; imageConverting = true;
selectedImageFile = null; selectedImageFile = null;
imageFileName = null; imageFileName = null;
@@ -172,6 +233,37 @@
</svelte:head> </svelte:head>
<main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8"> <main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
{#if showCamera}
<div
class="bg-background/95 fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Take receipt photo"
>
<div class="flex w-full max-w-lg flex-col gap-3">
{#if cameraError}
<p class="text-destructive text-sm">{cameraError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input aspect-video w-full rounded-lg border bg-black"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopCamera}>
Cancel
</Button>
{#if !cameraError}
<Button type="button" class="flex-1" onclick={capturePhoto}>
Capture
</Button>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a> <a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1> <h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
@@ -242,18 +334,17 @@
class="flex flex-col gap-4" class="flex flex-col gap-4"
> >
<input type="hidden" name="dataJson" value={buildDataJson()} /> <input type="hidden" name="dataJson" value={buildDataJson()} />
<div class="relative flex items-center gap-2"> <div class="relative flex flex-wrap items-center gap-2">
<input <input
id="receipt-image-input" id="receipt-image-input"
bind:this={imageInputEl} bind:this={imageInputEl}
type="file" type="file"
name="Image" name="Image"
accept="image/*" accept="image/*"
capture="environment"
onchange={onImageChange} onchange={onImageChange}
class="absolute opacity-0 w-0 h-0 overflow-hidden" class="absolute opacity-0 w-0 h-0 overflow-hidden pointer-events-none"
tabindex="-1" tabindex="-1"
aria-label={receipt.Image ? "Replace image" : "Add image"} aria-label="Choose image from gallery"
/> />
{#if imageConverting} {#if imageConverting}
<div <div
@@ -265,6 +356,9 @@
<span>Processing image…</span> <span>Processing image…</span>
</div> </div>
{:else} {:else}
<Button type="button" variant="outline" size="sm" onclick={startCamera}>
Take photo
</Button>
<label <label
for="receipt-image-input" for="receipt-image-input"
class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors" class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors"