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} +