From b4bbbcfc19e110a7bf44aeb4b1ca8ef23906fd68 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Fri, 11 Sep 2026 01:47:07 -0400 Subject: [PATCH 1/7] fix: store uploaded image key in images field field.onChange from useController takes a single value, so passing ('images', [data]) set the field to the string 'images' and the resolved schema rejected every submission that included an image. --- .../forms/FormImageUploadField/FormImageUploadField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx index a03a2e9f..d895cdd4 100644 --- a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx +++ b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx @@ -42,7 +42,7 @@ const FormImageUploadField = ({ return; } uploadImage(file, { - onSuccess: data => field.onChange('images', [data]) + onSuccess: data => field.onChange([data]) }); }; From 316270a7415d756056db60d4943b60632d751013 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Fri, 11 Sep 2026 01:47:15 -0400 Subject: [PATCH 2/7] feat: compress oversized images before upload Photos over 5 MB were rejected by the dropzone, so the resource forms silently submitted without an image. Downscale to 2048px and re-encode as JPEG (quality steps, then dimension steps) until the file fits, for both the Add a Site and Suggest an Edit forms. Animated GIFs pass through untouched. --- .../ImageUploader/ImageUploader.tsx | 15 +- .../FormImageUploadField.tsx | 17 ++- src/utils/compressImage.ts | 128 ++++++++++++++++++ 3 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 src/utils/compressImage.ts diff --git a/src/components/ImageUploader/ImageUploader.tsx b/src/components/ImageUploader/ImageUploader.tsx index b3789393..90ff24dc 100644 --- a/src/components/ImageUploader/ImageUploader.tsx +++ b/src/components/ImageUploader/ImageUploader.tsx @@ -1,7 +1,6 @@ import { Box, Stack, Typography } from '@mui/material'; import type { ReactNode } from 'react'; import { useDropzone, type DropzoneOptions } from 'react-dropzone'; -import fileSizePretty from 'utils/formatFileSize'; import { Upload } from 'icons'; export type RenderContentConfig = { @@ -10,23 +9,23 @@ export type RenderContentConfig = { type ImageUploaderProps = Pick< DropzoneOptions, - 'accept' | 'maxSize' | 'maxFiles' | 'onDrop' + 'accept' | 'maxFiles' | 'onDrop' > & { + helperText?: ReactNode; renderContent?: (config: RenderContentConfig) => ReactNode; }; const ImageUploader = ({ onDrop, accept, - maxSize, maxFiles, + helperText, renderContent = () => null }: ImageUploaderProps) => { const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({ onDrop, accept, - maxSize, maxFiles }); return ( @@ -34,9 +33,11 @@ const ImageUploader = ({ - - {maxSize ? `Max file size: ${fileSizePretty(maxSize)}` : null} - + {helperText ? ( + + {helperText} + + ) : null} {accept ? `accepted: ${Object.values(accept) diff --git a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx index d895cdd4..af945601 100644 --- a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx +++ b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx @@ -10,11 +10,12 @@ import { type FieldValues, type Path } from 'react-hook-form'; +import compressImage, { MAX_IMAGE_BYTES } from 'utils/compressImage'; +import formatFileSize from 'utils/formatFileSize'; type FormImageUploadFieldProps = { name: Path; accept?: Accept; - maxSize?: number; maxFiles?: number; renderContent?: (config: RenderContentConfig) => ReactNode; }; @@ -25,10 +26,13 @@ const defaultAccept = { 'image/gif': ['.gif'] } satisfies Accept; +const compressionHelperText = `Images over ${formatFileSize( + MAX_IMAGE_BYTES +)} are compressed automatically`; + const FormImageUploadField = ({ name, accept = defaultAccept, - maxSize = 5242880, maxFiles = 1, renderContent = () => null }: FormImageUploadFieldProps) => { @@ -36,12 +40,15 @@ const FormImageUploadField = ({ const { mutate: uploadImage } = useUploadImageMutation(); const { field } = useController({ control, name }); - const onDrop = (acceptedFiles: File[]) => { + const onDrop = async (acceptedFiles: File[]) => { const file = acceptedFiles.at(0); if (!file) { return; } - uploadImage(file, { + + const image = await compressImage(file).catch(() => file); + + uploadImage(image, { onSuccess: data => field.onChange([data]) }); }; @@ -50,8 +57,8 @@ const FormImageUploadField = ({ ); diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts new file mode 100644 index 00000000..874940da --- /dev/null +++ b/src/utils/compressImage.ts @@ -0,0 +1,128 @@ +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const MAX_IMAGE_DIMENSION = 2048; +const MIN_IMAGE_DIMENSION = 640; +const RESIZE_FACTOR = 0.75; +const QUALITY_STEPS = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4]; + +type CompressImageOptions = { + maxBytes?: number; + maxDimension?: number; +}; + +const decodeImage = async ( + file: File +): Promise => { + if (typeof createImageBitmap === 'function') { + const orientedBitmap = await createImageBitmap(file, { + imageOrientation: 'from-image' + }).catch(() => null); + if (orientedBitmap) { + return orientedBitmap; + } + + const bitmap = await createImageBitmap(file).catch(() => null); + if (bitmap) { + return bitmap; + } + } + + const objectUrl = URL.createObjectURL(file); + try { + const image = new Image(); + const loaded = new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error('Could not decode image')); + }); + image.src = objectUrl; + await loaded; + return image; + } finally { + URL.revokeObjectURL(objectUrl); + } +}; + +const canvasToBlob = (canvas: HTMLCanvasElement, quality: number) => + new Promise((resolve, reject) => { + canvas.toBlob( + blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Could not compress image')); + } + }, + 'image/jpeg', + quality + ); + }); + +const toJpegFile = (blob: Blob, originalFile: File) => { + const baseName = originalFile.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], `${baseName}.jpeg`, { + type: 'image/jpeg', + lastModified: originalFile.lastModified + }); +}; + +const compressImage = async ( + file: File, + { + maxBytes = MAX_IMAGE_BYTES, + maxDimension = MAX_IMAGE_DIMENSION + }: CompressImageOptions = {} +): Promise => { + const isCompressibleImage = + file.type.startsWith('image/') && file.type !== 'image/gif'; + + if (!isCompressibleImage || file.size <= maxBytes) { + return file; + } + + const source = await decodeImage(file); + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return file; + } + + const scale = Math.min( + 1, + maxDimension / Math.max(source.width, source.height) + ); + let width = Math.max(1, Math.round(source.width * scale)); + let height = Math.max(1, Math.round(source.height * scale)); + let compressed: Blob | null = null; + + while (true) { + canvas.width = width; + canvas.height = height; + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(source, 0, 0, width, height); + + for (const quality of QUALITY_STEPS) { + compressed = await canvasToBlob(canvas, quality); + if (compressed.size <= maxBytes) { + return toJpegFile(compressed, file); + } + } + + if (Math.max(width, height) <= MIN_IMAGE_DIMENSION) { + break; + } + + width = Math.max(1, Math.round(width * RESIZE_FACTOR)); + height = Math.max(1, Math.round(height * RESIZE_FACTOR)); + } + + return compressed ? toJpegFile(compressed, file) : file; + } finally { + if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) { + source.close(); + } + } +}; + +export { MAX_IMAGE_BYTES }; +export default compressImage; From 7926c778e9fd7a7f28203f0a0baaa0aad6885746 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Fri, 11 Sep 2026 01:47:20 -0400 Subject: [PATCH 3/7] test: cover compressing large image uploads Generates a 20 MB noisy PNG, drops it into the Add a Site form, and asserts the signed upload request switches to image/jpeg and the resource is submitted with the uploaded image key. --- cypress/e2e/desktop/imageCompression.cy.ts | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 cypress/e2e/desktop/imageCompression.cy.ts diff --git a/cypress/e2e/desktop/imageCompression.cy.ts b/cypress/e2e/desktop/imageCompression.cy.ts new file mode 100644 index 00000000..3b738b28 --- /dev/null +++ b/cypress/e2e/desktop/imageCompression.cy.ts @@ -0,0 +1,138 @@ +import { + autocompleteInput, + checkTag, + expectFormWasSubmitted, + input, + interceptResourceSubmitRequest, + nextPageOrSubmit, + openContributeMenu, + selectAddress, + selectEntryType, + selectResource +} from 'utils/crowdsourcing.ts'; +import { GUIDELINES_TEXTAREA } from 'utils/selectors.ts'; + +const FIVE_MB = 5 * 1024 * 1024; +const UPLOADED_IMAGE_KEY = 'compressed-image.jpeg'; + +type TestWindow = Window & typeof globalThis; + +const createNoisyPng = (win: TestWindow): Promise => { + const canvas = win.document.createElement('canvas'); + canvas.width = 3000; + canvas.height = 2000; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not create canvas context'); + } + + const imageData = context.createImageData(canvas.width, canvas.height); + const data = imageData.data; + const chunk = new Uint8Array(65536); + for (let offset = 0; offset < data.length; offset += chunk.length) { + win.crypto.getRandomValues(chunk); + data.set( + chunk.subarray(0, Math.min(chunk.length, data.length - offset)), + offset + ); + } + for (let alpha = 3; alpha < data.length; alpha += 4) { + data[alpha] = 255; + } + context.putImageData(imageData, 0, 0); + + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (!blob) { + reject(new Error('Could not create test image')); + return; + } + resolve(new win.File([blob], 'large-image.png', { type: 'image/png' })); + }, 'image/png'); + }); +}; + +const dropFile = (win: TestWindow, file: File) => { + const dataTransfer = new win.DataTransfer(); + dataTransfer.items.add(file); + + const fileInput = + win.document.querySelector('input[type="file"]'); + if (!fileInput) { + throw new Error('Could not find the image upload input'); + } + + fileInput.files = dataTransfer.files; + fileInput.dispatchEvent(new win.Event('change', { bubbles: true })); +}; + +describe('image compression', () => { + beforeEach(() => { + cy.visit('/'); + openContributeMenu(); + interceptResourceSubmitRequest(); + + cy.intercept( + { method: 'GET', hostname: 'phlask.me', pathname: '/submit-image' }, + { + statusCode: 200, + body: { + putURL: 'https://s3.test/compressed-image', + getURL: UPLOADED_IMAGE_KEY, + errorMessage: null + } + } + ).as('submitImageRequest'); + + cy.intercept( + { method: 'PUT', url: 'https://s3.test/compressed-image' }, + { statusCode: 200, body: '' } + ).as('uploadImageRequest'); + }); + + it('should compress an image over 5 MB before uploading it', () => { + selectResource('WATER'); + + input('name', 'Cypress Test Name'); + selectAddress(); + + input('description', 'Cypress Test Description'); + selectEntryType(); + + autocompleteInput('water.dispenser_type', 'Sink'); + + nextPageOrSubmit(); + + cy.window().then(async win => { + const file = await createNoisyPng(win); + expect(file.size, 'test image size').to.be.greaterThan(FIVE_MB); + dropFile(win, file); + }); + + cy.wait('@submitImageRequest', { timeout: 30000 }) + .its('request.url') + .should('include', 'type=image/jpeg'); + + cy.wait('@uploadImageRequest', { timeout: 30000 }).then(({ request }) => { + expect(request.headers['content-type']).to.equal('image/jpeg'); + expect( + Number(request.headers['content-length']), + 'uploaded image size' + ).to.be.lessThan(FIVE_MB); + }); + + checkTag(); + cy.get(GUIDELINES_TEXTAREA).type('Cypress Test'); + + nextPageOrSubmit(); + + cy.wait('@resourceSubmitRequest') + .its('request.body') + .then(body => { + expect(body.images).to.deep.equal([UPLOADED_IMAGE_KEY]); + }); + + expectFormWasSubmitted(); + }); +}); From ed95fd66eed78101d05247466d9dd2bcd4c0d81e Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Fri, 11 Sep 2026 03:54:52 -0400 Subject: [PATCH 4/7] refactor: remove compression helper text from image uploader Compression runs silently, and the extra copy just cluttered the dropzone. The uploader now shows only the accepted formats and the selected filename. --- src/components/ImageUploader/ImageUploader.tsx | 7 ------- .../forms/FormImageUploadField/FormImageUploadField.tsx | 8 +------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/components/ImageUploader/ImageUploader.tsx b/src/components/ImageUploader/ImageUploader.tsx index 90ff24dc..6567b1cb 100644 --- a/src/components/ImageUploader/ImageUploader.tsx +++ b/src/components/ImageUploader/ImageUploader.tsx @@ -11,7 +11,6 @@ type ImageUploaderProps = Pick< DropzoneOptions, 'accept' | 'maxFiles' | 'onDrop' > & { - helperText?: ReactNode; renderContent?: (config: RenderContentConfig) => ReactNode; }; @@ -19,7 +18,6 @@ const ImageUploader = ({ onDrop, accept, maxFiles, - helperText, renderContent = () => null }: ImageUploaderProps) => { const { getRootProps, getInputProps, isDragActive, acceptedFiles } = @@ -33,11 +31,6 @@ const ImageUploader = ({ - {helperText ? ( - - {helperText} - - ) : null} {accept ? `accepted: ${Object.values(accept) diff --git a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx index af945601..e14266d1 100644 --- a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx +++ b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx @@ -10,8 +10,7 @@ import { type FieldValues, type Path } from 'react-hook-form'; -import compressImage, { MAX_IMAGE_BYTES } from 'utils/compressImage'; -import formatFileSize from 'utils/formatFileSize'; +import compressImage from 'utils/compressImage'; type FormImageUploadFieldProps = { name: Path; @@ -26,10 +25,6 @@ const defaultAccept = { 'image/gif': ['.gif'] } satisfies Accept; -const compressionHelperText = `Images over ${formatFileSize( - MAX_IMAGE_BYTES -)} are compressed automatically`; - const FormImageUploadField = ({ name, accept = defaultAccept, @@ -58,7 +53,6 @@ const FormImageUploadField = ({ onDrop={onDrop} accept={accept} maxFiles={maxFiles} - helperText={compressionHelperText} renderContent={renderContent} /> ); From ddff9228afa5303a0c38109279bdce991312f308 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Tue, 15 Sep 2026 19:50:53 -0400 Subject: [PATCH 5/7] refactor: switch image compression to browser-image-compression library Hand-rolled canvas decode/resize/quality-stepping duplicated what a maintained library already solves, and ran entirely on the main thread. browser-image-compression gives the same behavior (downscale to 2048px, JPEG output, skip GIFs/already-small files) plus Web Worker offloading for free, at a fraction of the code. --- package.json | 1 + pnpm-lock.yaml | 15 +++++ src/utils/compressImage.ts | 128 +++++-------------------------------- 3 files changed, 32 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 7d9a2508..c668160a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@supabase/supabase-js": "^2.108.1", "@tanstack/react-query": "^5.101.0", "@vis.gl/react-google-maps": "^1.8.3", + "browser-image-compression": "^2.0.2", "date-fns": "^4.4.0", "posthog-js": "^1.383.3", "react": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 112bae0c..de639e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + browser-image-compression: + specifier: ^2.0.2 + version: 2.0.2 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -1716,6 +1719,9 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + browser-image-compression@2.0.2: + resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3508,6 +3514,9 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uzip@0.20201231.0: + resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} + verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} @@ -5096,6 +5105,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + browser-image-compression@2.0.2: + dependencies: + uzip: 0.20201231.0 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.35 @@ -7178,6 +7191,8 @@ snapshots: uuid@8.3.2: {} + uzip@0.20201231.0: {} + verror@1.10.0: dependencies: assert-plus: 1.0.0 diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts index 874940da..59b1f6ba 100644 --- a/src/utils/compressImage.ts +++ b/src/utils/compressImage.ts @@ -1,128 +1,32 @@ +import imageCompression from 'browser-image-compression'; + const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_IMAGE_DIMENSION = 2048; -const MIN_IMAGE_DIMENSION = 640; -const RESIZE_FACTOR = 0.75; -const QUALITY_STEPS = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4]; - -type CompressImageOptions = { - maxBytes?: number; - maxDimension?: number; -}; - -const decodeImage = async ( - file: File -): Promise => { - if (typeof createImageBitmap === 'function') { - const orientedBitmap = await createImageBitmap(file, { - imageOrientation: 'from-image' - }).catch(() => null); - if (orientedBitmap) { - return orientedBitmap; - } - - const bitmap = await createImageBitmap(file).catch(() => null); - if (bitmap) { - return bitmap; - } - } - - const objectUrl = URL.createObjectURL(file); - try { - const image = new Image(); - const loaded = new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = () => reject(new Error('Could not decode image')); - }); - image.src = objectUrl; - await loaded; - return image; - } finally { - URL.revokeObjectURL(objectUrl); - } -}; -const canvasToBlob = (canvas: HTMLCanvasElement, quality: number) => - new Promise((resolve, reject) => { - canvas.toBlob( - blob => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Could not compress image')); - } - }, - 'image/jpeg', - quality - ); - }); - -const toJpegFile = (blob: Blob, originalFile: File) => { - const baseName = originalFile.name.replace(/\.[^.]+$/, '') || 'image'; - return new File([blob], `${baseName}.jpeg`, { - type: 'image/jpeg', - lastModified: originalFile.lastModified +const renameToJpeg = (file: File) => { + const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([file], `${baseName}.jpeg`, { + type: file.type, + lastModified: file.lastModified }); }; -const compressImage = async ( - file: File, - { - maxBytes = MAX_IMAGE_BYTES, - maxDimension = MAX_IMAGE_DIMENSION - }: CompressImageOptions = {} -): Promise => { +const compressImage = async (file: File): Promise => { const isCompressibleImage = file.type.startsWith('image/') && file.type !== 'image/gif'; - if (!isCompressibleImage || file.size <= maxBytes) { + if (!isCompressibleImage || file.size <= MAX_IMAGE_BYTES) { return file; } - const source = await decodeImage(file); - try { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) { - return file; - } - - const scale = Math.min( - 1, - maxDimension / Math.max(source.width, source.height) - ); - let width = Math.max(1, Math.round(source.width * scale)); - let height = Math.max(1, Math.round(source.height * scale)); - let compressed: Blob | null = null; - - while (true) { - canvas.width = width; - canvas.height = height; - context.fillStyle = '#ffffff'; - context.fillRect(0, 0, width, height); - context.drawImage(source, 0, 0, width, height); - - for (const quality of QUALITY_STEPS) { - compressed = await canvasToBlob(canvas, quality); - if (compressed.size <= maxBytes) { - return toJpegFile(compressed, file); - } - } - - if (Math.max(width, height) <= MIN_IMAGE_DIMENSION) { - break; - } - - width = Math.max(1, Math.round(width * RESIZE_FACTOR)); - height = Math.max(1, Math.round(height * RESIZE_FACTOR)); - } + const compressed = await imageCompression(file, { + maxSizeMB: MAX_IMAGE_BYTES / (1024 * 1024), + maxWidthOrHeight: MAX_IMAGE_DIMENSION, + fileType: 'image/jpeg', + useWebWorker: true + }); - return compressed ? toJpegFile(compressed, file) : file; - } finally { - if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) { - source.close(); - } - } + return renameToJpeg(compressed); }; -export { MAX_IMAGE_BYTES }; export default compressImage; From a8a291acaf286fee0753151e46e93a4cd78069c9 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Tue, 20 Oct 2026 04:35:17 -0400 Subject: [PATCH 6/7] refactor: switch image compression to compressorjs browser-image-compression is unmaintained. Its last release was in March 2023. compressorjs is maintained, MIT licensed, and covers the same resize and JPEG conversion needs for image uploads. --- package.json | 2 +- pnpm-lock.yaml | 37 +++++++++++-------- .../FormImageUploadField.tsx | 5 ++- src/utils/compressImage.ts | 34 ++++++++++------- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index c668160a..44060c39 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@supabase/supabase-js": "^2.108.1", "@tanstack/react-query": "^5.101.0", "@vis.gl/react-google-maps": "^1.8.3", - "browser-image-compression": "^2.0.2", + "compressorjs": "^1.4.0", "date-fns": "^4.4.0", "posthog-js": "^1.383.3", "react": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de639e89..19f9f74e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,9 @@ importers: '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - browser-image-compression: - specifier: ^2.0.2 - version: 2.0.2 + compressorjs: + specifier: ^1.4.0 + version: 1.4.0 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -1712,6 +1712,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + blueimp-canvas-to-blob@3.29.0: + resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -1719,9 +1722,6 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} - browser-image-compression@2.0.2: - resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} - browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1837,6 +1837,9 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + compressorjs@1.4.0: + resolution: {integrity: sha512-cfBWoYc4EkIgsbphT7R0SwSHHY+zGSMBaBK0e0T+iPJejKpsIyco3OoQUdH6ghTVrH2bysXjhlO5zTDPJWzSmQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2466,6 +2469,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-blob@2.1.0: + resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==} + engines: {node: '>=6'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -3514,9 +3521,6 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - uzip@0.20201231.0: - resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} - verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} @@ -5096,6 +5100,8 @@ snapshots: bluebird@3.7.2: {} + blueimp-canvas-to-blob@3.29.0: {} + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -5105,10 +5111,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - browser-image-compression@2.0.2: - dependencies: - uzip: 0.20201231.0 - browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.35 @@ -5212,6 +5214,11 @@ snapshots: common-tags@1.8.2: {} + compressorjs@1.4.0: + dependencies: + blueimp-canvas-to-blob: 3.29.0 + is-blob: 2.1.0 + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -6014,6 +6021,8 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-blob@2.1.0: {} + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -7191,8 +7200,6 @@ snapshots: uuid@8.3.2: {} - uzip@0.20201231.0: {} - verror@1.10.0: dependencies: assert-plus: 1.0.0 diff --git a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx index e14266d1..4194d8af 100644 --- a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx +++ b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx @@ -41,7 +41,10 @@ const FormImageUploadField = ({ return; } - const image = await compressImage(file).catch(() => file); + const image = await compressImage(file).catch(error => { + console.error(error instanceof Error ? error.message : error); + return file; + }); uploadImage(image, { onSuccess: data => field.onChange([data]) diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts index 59b1f6ba..810cb673 100644 --- a/src/utils/compressImage.ts +++ b/src/utils/compressImage.ts @@ -1,16 +1,29 @@ -import imageCompression from 'browser-image-compression'; +import Compressor from 'compressorjs'; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_IMAGE_DIMENSION = 2048; -const renameToJpeg = (file: File) => { - const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'; - return new File([file], `${baseName}.jpeg`, { - type: file.type, - lastModified: file.lastModified +const renameToJpeg = (blob: Blob, original: File) => { + const baseName = original.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], `${baseName}.jpeg`, { + type: blob.type, + lastModified: original.lastModified }); }; +const compressToBlob = (file: File) => + new Promise((resolve, reject) => { + new Compressor(file, { + maxWidth: MAX_IMAGE_DIMENSION, + maxHeight: MAX_IMAGE_DIMENSION, + mimeType: 'image/jpeg', + convertSize: MAX_IMAGE_BYTES, + quality: 0.8, + success: resolve, + error: reject + }); + }); + const compressImage = async (file: File): Promise => { const isCompressibleImage = file.type.startsWith('image/') && file.type !== 'image/gif'; @@ -19,14 +32,9 @@ const compressImage = async (file: File): Promise => { return file; } - const compressed = await imageCompression(file, { - maxSizeMB: MAX_IMAGE_BYTES / (1024 * 1024), - maxWidthOrHeight: MAX_IMAGE_DIMENSION, - fileType: 'image/jpeg', - useWebWorker: true - }); + const compressed = await compressToBlob(file); - return renameToJpeg(compressed); + return renameToJpeg(compressed, file); }; export default compressImage; From 92922452dfb09cf116c566c335b512ed09d737a1 Mon Sep 17 00:00:00 2001 From: icycoldveins Date: Tue, 20 Oct 2026 04:35:20 -0400 Subject: [PATCH 7/7] chore: remove unused formatFileSize util No module imports formatFileSize, so the util is dead code. Removing it keeps the utils folder limited to code that has callers. --- src/utils/formatFileSize.ts | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/utils/formatFileSize.ts diff --git a/src/utils/formatFileSize.ts b/src/utils/formatFileSize.ts deleted file mode 100644 index 70958524..00000000 --- a/src/utils/formatFileSize.ts +++ /dev/null @@ -1,23 +0,0 @@ -type FormatFileSizeOptions = { - standard?: 'binary' | 'si'; -}; - -const formatFileSize = ( - bytes: number, - decimals = 1, - options: FormatFileSizeOptions = {} -) => { - if (bytes === 0) return '0 B'; - - const { standard = 'binary' } = options; - const k = standard === 'si' ? 1000 : 1024; - const sizes = - standard === 'si' - ? ['B', 'kB', 'MB', 'GB', 'TB', 'PB'] - : ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - const size = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)); - return `${size} ${sizes[i]}`; -}; - -export default formatFileSize;