From b72df41ec7c7c70c211dad901f13ecdee547c056 Mon Sep 17 00:00:00 2001 From: eewing Date: Thu, 26 Feb 2026 10:48:33 -0600 Subject: [PATCH] Enhance image upload handling by integrating HEIC to JPEG conversion in receipts routes. Update ReceiptForm and related components to improve user feedback during image processing, ensuring compatibility with HEIC files. Refactor image handling logic for better maintainability and user experience. --- .../src/routes/receipts/proxy+page.server.ts | 15 +++- src/lib/convert-heic-server.ts | 39 +++++++++++ src/lib/convert-heic.ts | 15 +++- src/lib/convert-image-to-webp.ts | 2 +- src/routes/receipts/+page.server.ts | 15 +++- src/routes/receipts/ReceiptForm.svelte | 33 +++++---- src/routes/receipts/[id]/+page.server.ts | 15 +++- src/routes/receipts/[id]/+page.svelte | 69 +++++++++++++------ 8 files changed, 162 insertions(+), 41 deletions(-) create mode 100644 src/lib/convert-heic-server.ts diff --git a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts index bd7bec70..15e2f676 100644 --- a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts @@ -2,6 +2,7 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; +import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server"; import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Receipts"; @@ -37,12 +38,24 @@ export const actions = { if (imgError) return fail(400, { error: imgError, Type }); } + let imageToSave: File | null = hasImage ? imageFile : null; + if (imageToSave) { + try { + imageToSave = await convertHeicFileToJpegIfNeeded(imageToSave); + } catch { + return fail(400, { + error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.", + Type, + }); + } + } + const body: Record = { Status: "New", Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase", User: user.id, }; - if (hasImage) body.Image = imageFile; + if (imageToSave) body.Image = imageToSave; try { await locals.pb.collection(COLLECTION).create(body); diff --git a/src/lib/convert-heic-server.ts b/src/lib/convert-heic-server.ts new file mode 100644 index 00000000..7134dc10 --- /dev/null +++ b/src/lib/convert-heic-server.ts @@ -0,0 +1,39 @@ +/** + * Server-side HEIC conversion using heic-convert (Node). Use in form actions only. + */ + +export function isHeicFile(file: File): boolean { + const name = file.name.toLowerCase(); + const type = (file.type ?? "").toLowerCase(); + return ( + name.endsWith(".heic") || + name.endsWith(".heif") || + type === "image/heic" || + type === "image/heif" + ); +} + +/** + * If the file is HEIC/HEIF, convert to JPEG and return a new File. Otherwise return the original. + */ +export async function convertHeicFileToJpegIfNeeded(file: File): Promise { + if (!isHeicFile(file)) return file; + + const arrayBuffer = await file.arrayBuffer(); + const inputBuffer = Buffer.from(arrayBuffer); + const convert = (await import("heic-convert")).default as (opts: { + buffer: Buffer; + format: string; + quality?: number; + }) => Promise; + const outputBuffer = await convert({ + buffer: inputBuffer, + format: "JPEG", + quality: 0.92, + }); + const baseName = file.name.replace(/\.[^.]+$/i, "") || "image"; + return new File([outputBuffer], `${baseName}.jpg`, { + type: "image/jpeg", + lastModified: Date.now(), + }); +} diff --git a/src/lib/convert-heic.ts b/src/lib/convert-heic.ts index b801bbab..a1763ccc 100644 --- a/src/lib/convert-heic.ts +++ b/src/lib/convert-heic.ts @@ -1,15 +1,25 @@ +function isHeifConsoleMessage(msg: string): boolean { + return /HEIF|parse HEIF|Could not parse/i.test(msg); +} + /** * Convert HEIC/HEIF blob to JPEG. Uses heic2any only when this function is called * (dynamic import), so the receipt page can load even if heic2any fails to resolve. - * Suppresses the library's "Could not parse HEIF file" console errors during conversion. + * Suppresses the library's "Could not parse HEIF file" console output during conversion. */ export async function convertHeicBlobToJpeg(blob: Blob): Promise { const originalError = console.error; + const originalWarn = console.warn; console.error = (...args: unknown[]) => { const msg = args[0]?.toString?.() ?? ""; - if (msg.includes("HEIF") || msg.includes("parse HEIF")) return; + if (isHeifConsoleMessage(msg)) return; originalError.apply(console, args); }; + console.warn = (...args: unknown[]) => { + const msg = args[0]?.toString?.() ?? ""; + if (isHeifConsoleMessage(msg)) return; + originalWarn.apply(console, args); + }; try { const mod = await import(/* @vite-ignore */ "heic2any"); const heic2any = (mod as unknown as { @@ -19,5 +29,6 @@ export async function convertHeicBlobToJpeg(blob: Blob): Promise { return Array.isArray(out) ? out[0] : out; } finally { console.error = originalError; + console.warn = originalWarn; } } diff --git a/src/lib/convert-image-to-webp.ts b/src/lib/convert-image-to-webp.ts index cdaf9623..50d1fb23 100644 --- a/src/lib/convert-image-to-webp.ts +++ b/src/lib/convert-image-to-webp.ts @@ -1,6 +1,6 @@ import { convertHeicBlobToJpeg } from "./convert-heic"; -function isHeicFile(file: File): boolean { +export function isHeicFile(file: File): boolean { const name = file.name.toLowerCase(); const type = (file.type ?? "").toLowerCase(); return ( diff --git a/src/routes/receipts/+page.server.ts b/src/routes/receipts/+page.server.ts index ba01ee3d..3d848884 100644 --- a/src/routes/receipts/+page.server.ts +++ b/src/routes/receipts/+page.server.ts @@ -1,6 +1,7 @@ import { fail } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; +import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server"; import { validateImageUpload } from "$lib/validate-image-upload"; const COLLECTION = "Stackq_Receipts"; @@ -36,12 +37,24 @@ export const actions: Actions = { if (imgError) return fail(400, { error: imgError, Type }); } + let imageToSave: File | null = hasImage ? imageFile : null; + if (imageToSave) { + try { + imageToSave = await convertHeicFileToJpegIfNeeded(imageToSave); + } catch { + return fail(400, { + error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.", + Type, + }); + } + } + const body: Record = { Status: "New", Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase", User: user.id, }; - if (hasImage) body.Image = imageFile; + if (imageToSave) body.Image = imageToSave; try { await locals.pb.collection(COLLECTION).create(body); diff --git a/src/routes/receipts/ReceiptForm.svelte b/src/routes/receipts/ReceiptForm.svelte index 6f6e2ac0..d1c7b02f 100644 --- a/src/routes/receipts/ReceiptForm.svelte +++ b/src/routes/receipts/ReceiptForm.svelte @@ -1,7 +1,7 @@ diff --git a/src/routes/receipts/[id]/+page.server.ts b/src/routes/receipts/[id]/+page.server.ts index 644c6894..120f1399 100644 --- a/src/routes/receipts/[id]/+page.server.ts +++ b/src/routes/receipts/[id]/+page.server.ts @@ -2,6 +2,7 @@ import { error, fail, redirect } from "@sveltejs/kit"; import type { Actions, PageServerLoad } from "./$types"; import type { StackqReceipt } from "$lib/types/receipts"; import type { ReceiptDataShape } from "$lib/types/receipts"; +import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server"; import { parseNumWithDefault } from "$lib/parse-form"; import { validateImageUpload } from "$lib/validate-image-upload"; @@ -45,6 +46,18 @@ export const actions: Actions = { if (imgError) return fail(400, { error: imgError, form: "update" }); } + let imageToSave: File | null = hasImage ? imageFile : null; + if (imageToSave) { + try { + imageToSave = await convertHeicFileToJpegIfNeeded(imageToSave); + } catch { + return fail(400, { + error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.", + form: "update", + }); + } + } + let Data: ReceiptDataShape = {}; try { const parsed = JSON.parse(dataJson) as unknown; @@ -83,7 +96,7 @@ export const actions: Actions = { Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase", Data: Object.keys(Data).length ? Data : undefined, }; - if (hasImage) body.Image = imageFile; + if (imageToSave) body.Image = imageToSave; try { await locals.pb.collection(COLLECTION).update(id, body); diff --git a/src/routes/receipts/[id]/+page.svelte b/src/routes/receipts/[id]/+page.svelte index 1afcd143..1ba80919 100644 --- a/src/routes/receipts/[id]/+page.svelte +++ b/src/routes/receipts/[id]/+page.svelte @@ -2,9 +2,10 @@ import { Button } from "$lib/components/ui/button"; import { Card, CardContent } from "$lib/components/ui/card"; import * as Collapsible from "$lib/components/ui/collapsible"; + import * as Dialog from "$lib/components/ui/dialog"; import { Input } from "$lib/components/ui/input"; import { Label } from "$lib/components/ui/label"; - import { convertImageToWebP, isAcceptableImageFile } from "$lib/convert-image-to-webp"; + import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp"; import { POCKETBASE_BASE_URL } from "$lib/pocketbase"; import { enhance } from "$app/forms"; import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; @@ -128,6 +129,7 @@ /** On small screens image is collapsed by default; on lg+ expanded. */ let imageOpen = $state(false); let imageOpenInitialized = $state(false); + let imageFullscreenOpen = $state(false); $effect(() => { if (typeof window === "undefined" || imageOpenInitialized) return; imageOpenInitialized = true; @@ -143,20 +145,25 @@ imageFileName = null; return; } - imageConverting = true; selectedImageFile = null; imageFileName = null; - await new Promise((r) => setTimeout(r, 0)); - try { - const webpFile = await convertImageToWebP(file); - selectedImageFile = webpFile; - imageFileName = webpFile.name; - } catch { - selectedImageFile = null; - imageFileName = null; - if (input) input.value = ""; - } finally { - imageConverting = false; + if (isHeicFile(file)) { + selectedImageFile = file; + imageFileName = file.name + " (will convert on server)"; + } else { + imageConverting = true; + await new Promise((r) => setTimeout(r, 0)); + try { + const webpFile = await convertImageToWebP(file); + selectedImageFile = webpFile; + imageFileName = webpFile.name; + } catch { + selectedImageFile = null; + imageFileName = null; + if (input) input.value = ""; + } finally { + imageConverting = false; + } } } function imageLoadError() { @@ -206,13 +213,20 @@ Download image {:else} - Receipt + {/if} {:else} @@ -437,6 +451,21 @@ + {#if currentImageUrl} + + + Receipt (full screen) + + + {/if} +