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.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { fail } from "@sveltejs/kit";
|
import { fail } from "@sveltejs/kit";
|
||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import type { StackqReceipt } from "$lib/types/receipts";
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server";
|
||||||
import { validateImageUpload } from "$lib/validate-image-upload";
|
import { validateImageUpload } from "$lib/validate-image-upload";
|
||||||
|
|
||||||
const COLLECTION = "Stackq_Receipts";
|
const COLLECTION = "Stackq_Receipts";
|
||||||
@@ -37,12 +38,24 @@ export const actions = {
|
|||||||
if (imgError) return fail(400, { error: imgError, Type });
|
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<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
Status: "New",
|
Status: "New",
|
||||||
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
User: user.id,
|
User: user.id,
|
||||||
};
|
};
|
||||||
if (hasImage) body.Image = imageFile;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await locals.pb.collection(COLLECTION).create(body);
|
await locals.pb.collection(COLLECTION).create(body);
|
||||||
|
|||||||
@@ -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<File> {
|
||||||
|
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<Buffer>;
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
+13
-2
@@ -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
|
* 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.
|
* (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<Blob> {
|
export async function convertHeicBlobToJpeg(blob: Blob): Promise<Blob> {
|
||||||
const originalError = console.error;
|
const originalError = console.error;
|
||||||
|
const originalWarn = console.warn;
|
||||||
console.error = (...args: unknown[]) => {
|
console.error = (...args: unknown[]) => {
|
||||||
const msg = args[0]?.toString?.() ?? "";
|
const msg = args[0]?.toString?.() ?? "";
|
||||||
if (msg.includes("HEIF") || msg.includes("parse HEIF")) return;
|
if (isHeifConsoleMessage(msg)) return;
|
||||||
originalError.apply(console, args);
|
originalError.apply(console, args);
|
||||||
};
|
};
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
const msg = args[0]?.toString?.() ?? "";
|
||||||
|
if (isHeifConsoleMessage(msg)) return;
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const mod = await import(/* @vite-ignore */ "heic2any");
|
const mod = await import(/* @vite-ignore */ "heic2any");
|
||||||
const heic2any = (mod as unknown as {
|
const heic2any = (mod as unknown as {
|
||||||
@@ -19,5 +29,6 @@ export async function convertHeicBlobToJpeg(blob: Blob): Promise<Blob> {
|
|||||||
return Array.isArray(out) ? out[0] : out;
|
return Array.isArray(out) ? out[0] : out;
|
||||||
} finally {
|
} finally {
|
||||||
console.error = originalError;
|
console.error = originalError;
|
||||||
|
console.warn = originalWarn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { convertHeicBlobToJpeg } from "./convert-heic";
|
import { convertHeicBlobToJpeg } from "./convert-heic";
|
||||||
|
|
||||||
function isHeicFile(file: File): boolean {
|
export function isHeicFile(file: File): boolean {
|
||||||
const name = file.name.toLowerCase();
|
const name = file.name.toLowerCase();
|
||||||
const type = (file.type ?? "").toLowerCase();
|
const type = (file.type ?? "").toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fail } from "@sveltejs/kit";
|
import { fail } from "@sveltejs/kit";
|
||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import type { StackqReceipt } from "$lib/types/receipts";
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server";
|
||||||
import { validateImageUpload } from "$lib/validate-image-upload";
|
import { validateImageUpload } from "$lib/validate-image-upload";
|
||||||
|
|
||||||
const COLLECTION = "Stackq_Receipts";
|
const COLLECTION = "Stackq_Receipts";
|
||||||
@@ -36,12 +37,24 @@ export const actions: Actions = {
|
|||||||
if (imgError) return fail(400, { error: imgError, Type });
|
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<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
Status: "New",
|
Status: "New",
|
||||||
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
User: user.id,
|
User: user.id,
|
||||||
};
|
};
|
||||||
if (hasImage) body.Image = imageFile;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await locals.pb.collection(COLLECTION).create(body);
|
await locals.pb.collection(COLLECTION).create(body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Label } from "$lib/components/ui/label";
|
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 { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
||||||
|
|
||||||
@@ -34,9 +34,11 @@
|
|||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
imageConverting = true;
|
|
||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
// Let the UI paint "Processing image…" before we block the main thread
|
if (isHeicFile(file)) {
|
||||||
|
imageFileName = file.name + " (will convert on server)";
|
||||||
|
} else {
|
||||||
|
imageConverting = true;
|
||||||
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);
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
imageConverting = false;
|
imageConverting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { error, fail, redirect } from "@sveltejs/kit";
|
|||||||
import type { Actions, PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import type { StackqReceipt } from "$lib/types/receipts";
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
import type { ReceiptDataShape } 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 { parseNumWithDefault } from "$lib/parse-form";
|
||||||
import { validateImageUpload } from "$lib/validate-image-upload";
|
import { validateImageUpload } from "$lib/validate-image-upload";
|
||||||
|
|
||||||
@@ -45,6 +46,18 @@ export const actions: Actions = {
|
|||||||
if (imgError) return fail(400, { error: imgError, form: "update" });
|
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 = {};
|
let Data: ReceiptDataShape = {};
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(dataJson) as unknown;
|
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",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
Data: Object.keys(Data).length ? Data : undefined,
|
Data: Object.keys(Data).length ? Data : undefined,
|
||||||
};
|
};
|
||||||
if (hasImage) body.Image = imageFile;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await locals.pb.collection(COLLECTION).update(id, body);
|
await locals.pb.collection(COLLECTION).update(id, body);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import * as Collapsible from "$lib/components/ui/collapsible";
|
import * as Collapsible from "$lib/components/ui/collapsible";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
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 { POCKETBASE_BASE_URL } from "$lib/pocketbase";
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
/** 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);
|
||||||
|
let imageFullscreenOpen = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === "undefined" || imageOpenInitialized) return;
|
if (typeof window === "undefined" || imageOpenInitialized) return;
|
||||||
imageOpenInitialized = true;
|
imageOpenInitialized = true;
|
||||||
@@ -143,9 +145,13 @@
|
|||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
imageConverting = true;
|
|
||||||
selectedImageFile = null;
|
selectedImageFile = null;
|
||||||
imageFileName = null;
|
imageFileName = null;
|
||||||
|
if (isHeicFile(file)) {
|
||||||
|
selectedImageFile = file;
|
||||||
|
imageFileName = file.name + " (will convert on server)";
|
||||||
|
} else {
|
||||||
|
imageConverting = true;
|
||||||
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);
|
||||||
@@ -159,6 +165,7 @@
|
|||||||
imageConverting = false;
|
imageConverting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function imageLoadError() {
|
function imageLoadError() {
|
||||||
imageError = true;
|
imageError = true;
|
||||||
}
|
}
|
||||||
@@ -206,6 +213,12 @@
|
|||||||
<a href={url} download class="text-primary hover:underline text-sm">Download image</a>
|
<a href={url} download class="text-primary hover:underline text-sm">Download image</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (imageFullscreenOpen = true)}
|
||||||
|
class="focus:ring-ring hover:opacity-95 h-auto w-full rounded object-contain focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
|
aria-label="View receipt image full screen"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt="Receipt"
|
alt="Receipt"
|
||||||
@@ -213,6 +226,7 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror={imageLoadError}
|
onerror={imageLoadError}
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -437,6 +451,21 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if currentImageUrl}
|
||||||
|
<Dialog.Root bind:open={imageFullscreenOpen}>
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed inset-0 z-50 flex max-h-none max-w-none flex-col items-center justify-center gap-4 border-0 bg-black/95 p-4 text-white focus:outline-none"
|
||||||
|
showCloseButton={true}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={currentImageUrl}
|
||||||
|
alt="Receipt (full screen)"
|
||||||
|
class="max-h-[calc(100vh-4rem)] max-w-full cursor-default object-contain"
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="flex flex-row flex-wrap items-center justify-between gap-4 py-4">
|
<CardContent class="flex flex-row flex-wrap items-center justify-between gap-4 py-4">
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user