From d825ab709d3842418e5cc5f9d56ddc53246d7923 Mon Sep 17 00:00:00 2001 From: Mikael Siidorow Date: Tue, 3 Mar 2026 20:35:34 +0200 Subject: [PATCH] feat(invoice-generator): add client-side image compression Compress image attachments eagerly when selected, before form submission. This significantly reduces payload size for receipt photos (often 90%+ reduction), helping avoid rate limiting issues with large uploads. Features: - Eager compression on file selection with loading spinner - Image preview thumbnails after compression - File size display with compression ratio - Lazy-loaded browser-image-compression library - Web Worker support for non-blocking compression Fixes #551 Co-Authored-By: Claude Opus 4.5 --- apps/web/package.json | 1 + .../components/invoice-generator/index.tsx | 329 ++++++++++++++++-- apps/web/src/lib/compress-image.ts | 116 ++++++ apps/web/src/locales/en.ts | 2 + apps/web/src/locales/fi.ts | 2 + pnpm-lock.yaml | 15 + 6 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/lib/compress-image.ts diff --git a/apps/web/package.json b/apps/web/package.json index d375d4ba..ae45a024 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@tietokilta/ilmomasiina-client": "3.0.0-alpha.7", "@tietokilta/ilmomasiina-models": "3.0.0-alpha.7", "@tietokilta/ui": "workspace:*", + "browser-image-compression": "^2.0.2", "clsx": "catalog:", "date-fns": "^4.1.0", "mailgun.js": "^12.7.0", diff --git a/apps/web/src/components/invoice-generator/index.tsx b/apps/web/src/components/invoice-generator/index.tsx index eeabb6b9..9a5934ae 100644 --- a/apps/web/src/components/invoice-generator/index.tsx +++ b/apps/web/src/components/invoice-generator/index.tsx @@ -10,7 +10,9 @@ import { useState, startTransition, type FormEvent, + type ChangeEvent, useRef, + useCallback, } from "react"; import Form from "next/form"; import { toast } from "sonner"; @@ -21,6 +23,16 @@ import { NextIntlClientProvider, useTranslations } from "../../locales/client"; import { locales, type Messages } from "../../locales/index"; import { SaveAction } from "../../lib/api/external/laskugeneraattori/actions"; import { type InvoiceGeneratorFormState } from "../../lib/api/external/laskugeneraattori/index"; +import { compressImage, isCompressibleImage } from "../../lib/compress-image"; + +interface ProcessedAttachment { + id: number; + originalFile: File; + processedFile: File; + previewUrl: string | null; + isProcessing: boolean; + compressionRatio: number | null; +} const MAX_PAYLOAD_SIZE = 24 * 1024 * 1024; // 24MB in bytes @@ -84,8 +96,10 @@ function TextAreaInputRow({ function SubmitButton({ formState, + disabled, }: { formState: InvoiceGeneratorFormState | null; + disabled?: boolean; }) { const t = useTranslations("invoicegenerator"); const { pending } = useFormStatus(); @@ -97,7 +111,11 @@ function SubmitButton({ return (
- {formState?.success === false && errorKeys.length === 0 ? ( @@ -258,49 +276,281 @@ function InvoiceItem({ ); } -function AttachmentRow({ - state, +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function AttachmentInput({ + attachment, index, + onFileChange, + onRemove, + formState, + canRemove, }: { - state: InvoiceGeneratorFormState | null; + attachment: ProcessedAttachment; index: number; + onFileChange: (id: number, file: File) => void; + onRemove: (id: number) => void; + formState: InvoiceGeneratorFormState | null; + canRemove: boolean; }) { const t = useTranslations("invoicegenerator"); + const fileInputRef = useRef(null); + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFileChange(attachment.id, file); + } + }; + + const isImage = attachment.processedFile.type.startsWith("image/"); + const showPreview = + isImage && attachment.previewUrl && !attachment.isProcessing; return ( -
- - - - +

+ {t("Attachment")} {index + 1} +

+ +
+ + + + + +
+ + +
+
+ + {attachment.isProcessing ? ( +
+ + {t("Compressing image")}... +
+ ) : null} + + {showPreview && attachment.previewUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- Preview of user-uploaded file */} + {t("Attachment +
+ {attachment.compressionRatio !== null && + attachment.compressionRatio < 1 ? ( + + {formatFileSize(attachment.originalFile.size)} →{" "} + {formatFileSize(attachment.processedFile.size)}{" "} + + (-{Math.round((1 - attachment.compressionRatio) * 100)}%) + + + ) : ( + {formatFileSize(attachment.processedFile.size)} + )} +
+
+ ) : null} + + {!isImage && attachment.processedFile.size > 0 ? ( +
+ {attachment.processedFile.name} ( + {formatFileSize(attachment.processedFile.size)}) +
+ ) : null} +
+ +
+ {t("Remove")} + +
+ ); +} + +function AttachmentsSection({ + formState, + attachmentsRef, + onProcessingChange, +}: { + formState: InvoiceGeneratorFormState | null; + attachmentsRef: React.RefObject; + onProcessingChange: (isProcessing: boolean) => void; +}) { + const t = useTranslations("invoicegenerator"); + const [attachments, setAttachments] = useState([]); + const [idCounter, setIdCounter] = useState(0); + + // Sync attachments to ref for form submission + useEffect(() => { + // Using Object.assign to mutate the ref's current array in place + attachmentsRef.current.length = 0; + attachmentsRef.current.push(...attachments); + }, [attachments, attachmentsRef]); + + // Notify parent of processing state changes + useEffect(() => { + const isAnyProcessing = attachments.some((a) => a.isProcessing); + onProcessingChange(isAnyProcessing); + }, [attachments, onProcessingChange]); + + const addAttachment = () => { + const emptyFile = new File([], ""); + const newAttachment: ProcessedAttachment = { + id: idCounter, + originalFile: emptyFile, + processedFile: emptyFile, + previewUrl: null, + isProcessing: false, + compressionRatio: null, + }; + setAttachments((prev) => [...prev, newAttachment]); + setIdCounter((prev) => prev + 1); + }; + + const removeAttachment = (id: number) => { + setAttachments((prev) => { + const attachment = prev.find((a) => a.id === id); + if (attachment?.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + return prev.filter((a) => a.id !== id); + }); + }; + + const handleFileChange = useCallback(async (id: number, file: File) => { + // Mark as processing + setAttachments((prev) => + prev.map((a) => + a.id === id + ? { ...a, originalFile: file, isProcessing: true, previewUrl: null } + : a, + ), + ); + + try { + let processedFile = file; + let compressionRatio: number | null = null; + + // Compress if it's an image + if (isCompressibleImage(file)) { + processedFile = await compressImage(file); + compressionRatio = processedFile.size / file.size; + } + + // Create preview URL for images + let previewUrl: string | null = null; + if (processedFile.type.startsWith("image/")) { + previewUrl = URL.createObjectURL(processedFile); + } + + setAttachments((prev) => + prev.map((a) => + a.id === id + ? { + ...a, + processedFile, + previewUrl, + isProcessing: false, + compressionRatio, + } + : a, + ), + ); + } catch (error) { + console.error("Failed to process attachment:", error); + // Fall back to original file + setAttachments((prev) => + prev.map((a) => + a.id === id + ? { + ...a, + processedFile: file, + isProcessing: false, + compressionRatio: null, + } + : a, + ), + ); + } + }, []); + + // Cleanup preview URLs on unmount + useEffect(() => { + return () => { + attachments.forEach((a) => { + if (a.previewUrl) { + URL.revokeObjectURL(a.previewUrl); + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only cleanup on unmount + }, []); + + return ( +
+

{t("Attachments")}

+
+ {attachments.map((attachment, index) => ( + 0} + /> + ))} +
+ +
); } function InvoiceGeneratorContent() { const [state, formAction] = useActionState(SaveAction, null); + const [isProcessingAttachments, setIsProcessingAttachments] = useState(false); const t = useTranslations("invoicegenerator"); const formRef = useRef(null); + const attachmentsRef = useRef([]); const isAndroidFirefox = useIsAndroidFirefox(); // Form submission handler that doesn't reset the form @@ -312,6 +562,15 @@ function InvoiceGeneratorContent() { event.preventDefault(); const formData = new FormData(event.target); + // Replace file input attachments with processed (compressed) versions + formData.delete("attachments"); + const processedAttachments = attachmentsRef.current ?? []; + for (const attachment of processedAttachments) { + if (attachment.processedFile.size > 0) { + formData.append("attachments", attachment.processedFile); + } + } + const payloadSize = calculateFormDataSize(formData); if (payloadSize > MAX_PAYLOAD_SIZE) { @@ -477,19 +736,17 @@ function InvoiceGeneratorContent() { minimumRows={1} /> -
- +
); diff --git a/apps/web/src/lib/compress-image.ts b/apps/web/src/lib/compress-image.ts new file mode 100644 index 00000000..652c5eb1 --- /dev/null +++ b/apps/web/src/lib/compress-image.ts @@ -0,0 +1,116 @@ +/** + * Client-side image compression utility for the invoice generator. + * + * Uses browser-image-compression for now as a pragmatic choice that works + * without special server headers. + * + * TODO: Consider upgrading to wasm-vips for better compression quality. + * wasm-vips requires Cross-Origin-Embedder-Policy (COEP) and + * Cross-Origin-Opener-Policy (COOP) headers, which currently conflict with + * Google Forms embeds used elsewhere on the site. + * + * When browser support for `credentialless` iframes improves, we can: + * 1. Add `credentialless` attribute to Google Forms iframes + * 2. Enable COEP/COOP headers site-wide (or per-route) + * 3. Switch to wasm-vips for superior compression + * + * Browser support for credentialless iframes: + * @see https://caniuse.com/mdn-html_elements_iframe_credentialless + * + * wasm-vips library: + * @see https://github.com/kleisauke/wasm-vips + */ + +import type { Options } from "browser-image-compression"; + +/** Default compression options optimized for receipt photos */ +const DEFAULT_OPTIONS: Options = { + maxSizeMB: 1, + maxWidthOrHeight: 2048, + useWebWorker: true, + fileType: "image/jpeg", + initialQuality: 0.8, +}; + +/** + * Check if a file is a compressible image type + */ +export function isCompressibleImage(file: File): boolean { + const compressibleTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/bmp", + ]; + return compressibleTypes.includes(file.type); +} + +/** + * Compress an image file using browser-image-compression. + * Returns the original file if it's not a compressible image type. + * + * @param file - The file to compress + * @param options - Optional compression options to override defaults + * @returns Promise resolving to the compressed file (or original if not an image) + */ +export async function compressImage( + file: File, + options?: Partial, +): Promise { + if (!isCompressibleImage(file)) { + return file; + } + + // Lazy load the compression library + const imageCompression = await import("browser-image-compression").then( + (mod) => mod.default, + ); + + const mergedOptions: Options = { + ...DEFAULT_OPTIONS, + ...options, + }; + + try { + const compressedBlob = await imageCompression(file, mergedOptions); + + // Preserve original filename but potentially change extension for JPEG conversion + let filename = file.name; + if ( + mergedOptions.fileType === "image/jpeg" && + !filename.toLowerCase().endsWith(".jpg") && + !filename.toLowerCase().endsWith(".jpeg") + ) { + // Replace extension with .jpg for converted files + const lastDot = filename.lastIndexOf("."); + if (lastDot > 0) { + filename = `${filename.substring(0, lastDot)}.jpg`; + } else { + filename = `${filename}.jpg`; + } + } + + return new File([compressedBlob], filename, { + type: compressedBlob.type, + lastModified: Date.now(), + }); + } catch (error) { + console.warn("Image compression failed, using original file:", error); + return file; + } +} + +/** + * Compress multiple files, processing images and passing through other file types. + * + * @param files - Array of files to process + * @param options - Optional compression options + * @returns Promise resolving to array of processed files + */ +export async function compressImages( + files: File[], + options?: Partial, +): Promise { + return Promise.all(files.map((file) => compressImage(file, options))); +} diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts index 04ea861f..2b70105e 100644 --- a/apps/web/src/locales/en.ts +++ b/apps/web/src/locales/en.ts @@ -121,6 +121,8 @@ const en = { "Total price": "Total price", Attachment: "Attachment", Attachments: "Attachments", + "Attachment preview": "Attachment preview", + "Compressing image": "Compressing image", Items: "Items", "Receipt/Product": "Receipt/Product", "Sent invoice": "Sent invoice", diff --git a/apps/web/src/locales/fi.ts b/apps/web/src/locales/fi.ts index 31a741c3..2d5a20b9 100644 --- a/apps/web/src/locales/fi.ts +++ b/apps/web/src/locales/fi.ts @@ -121,6 +121,8 @@ const fi = { "Total price": "Yhteensä", Attachment: "Liite", Attachments: "Liitteet", + "Attachment preview": "Liitteen esikatselu", + "Compressing image": "Pakataan kuvaa", Items: "Erittely", "Receipt/Product": "Kuitti/Tuote", "Sent invoice": "Lasku lähetetty", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 088609b9..fba348a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@tietokilta/ui': specifier: workspace:* version: link:../../packages/ui + browser-image-compression: + specifier: ^2.0.2 + version: 2.0.2 clsx: specifier: 'catalog:' version: 2.1.1 @@ -3457,6 +3460,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-image-compression@2.0.2: + resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} + browserslist@4.26.0: resolution: {integrity: sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -6726,6 +6732,9 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uzip@0.20201231.0: + resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -10167,6 +10176,10 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-image-compression@2.0.2: + dependencies: + uzip: 0.20201231.0 + browserslist@4.26.0: dependencies: baseline-browser-mapping: 2.9.16 @@ -14199,6 +14212,8 @@ snapshots: uuid@10.0.0: {} + uzip@0.20201231.0: {} + vary@1.1.2: {} vfile-message@4.0.2: