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 (
-
+
+ );
+}
+
+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}
+ />
+ ))}
+
+
+ {t("Add")}
+
+
);
}
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: