From 416132571e7a87da617a7af7b01c8304cd67e5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sat, 28 Mar 2026 14:19:32 +0100 Subject: [PATCH] Client-side image compression before upload Compress large photos (>500KB) via Canvas API before form submission, reducing upload size from 3-6MB to ~300-500KB. This cuts upload time on slow mobile connections from ~40s to ~3s, preventing 499 errors. The submit button shows "Compressing images..." during compression, then "Saving..." during upload, keeping the user informed and preventing re-submission. Closes #100 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../submit_prevention_controller.js | 179 +++++++++++++++++- templates/_solving_time_form.html.twig | 5 +- translations/messages.cs.yml | 1 + translations/messages.de.yml | 1 + translations/messages.en.yml | 1 + translations/messages.es.yml | 1 + translations/messages.fr.yml | 1 + translations/messages.ja.yml | 1 + 8 files changed, 183 insertions(+), 7 deletions(-) diff --git a/assets/controllers/submit_prevention_controller.js b/assets/controllers/submit_prevention_controller.js index dcadb4a9..145e1660 100644 --- a/assets/controllers/submit_prevention_controller.js +++ b/assets/controllers/submit_prevention_controller.js @@ -1,17 +1,33 @@ import { Controller } from '@hotwired/stimulus'; +const COMPRESS_THRESHOLD_BYTES = 500 * 1024; +const MAX_DIMENSION = 2000; +const JPEG_QUALITY = 0.85; + export default class extends Controller { - static targets = ["submit"]; + static targets = ["submit", "label"]; static values = { - isSubmitting: Boolean - } + isSubmitting: Boolean, + compressImages: { type: Boolean, default: false }, + compressingText: { type: String, default: 'Compressing images...' }, + savingText: { type: String, default: 'Saving...' }, + }; connect() { this.isSubmittingValue = false; - this.element.addEventListener('submit', this.preventDuplicateSubmission.bind(this)); + this.compressionDone = false; + this.originalLabelHtml = null; + + this.boundPrevent = this.preventDuplicateSubmission.bind(this); + this.boundReset = this.reset.bind(this); - // Re-enable button when Turbo finishes (success, redirect, or error) - this.element.addEventListener('turbo:submit-end', this.reset.bind(this)); + this.element.addEventListener('submit', this.boundPrevent); + this.element.addEventListener('turbo:submit-end', this.boundReset); + } + + disconnect() { + this.element.removeEventListener('submit', this.boundPrevent); + this.element.removeEventListener('turbo:submit-end', this.boundReset); } preventDuplicateSubmission(event) { @@ -20,13 +36,139 @@ export default class extends Controller { return; } + if (this.compressImagesValue && !this.compressionDone) { + const fileInputs = this.element.querySelectorAll('.file-drop-input'); + const filesToCompress = this.findFilesToCompress(fileInputs); + + if (filesToCompress.length > 0) { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.isSubmittingValue = true; + this.disableSubmitButton(); + this.showCompressingState(); + + this.compressAllFiles(filesToCompress).then(() => { + this.compressionDone = true; + this.showSavingState(); + this.isSubmittingValue = false; + this.element.requestSubmit(); + }); + + return; + } + } + this.isSubmittingValue = true; this.disableSubmitButton(); + this.showSavingState(); } reset() { this.isSubmittingValue = false; + this.compressionDone = false; this.enableSubmitButton(); + this.restoreLabel(); + } + + findFilesToCompress(fileInputs) { + const result = []; + + fileInputs.forEach(input => { + if (!input.files || !input.files[0]) return; + + const file = input.files[0]; + if (file.size <= COMPRESS_THRESHOLD_BYTES) return; + if (file.type === 'image/gif') return; + if (!file.type.startsWith('image/')) return; + + result.push({ input, file }); + }); + + return result; + } + + async compressAllFiles(filesToCompress) { + for (const { input, file } of filesToCompress) { + try { + const compressedFile = await this.compressImage(file); + + if (compressedFile.size < file.size) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(compressedFile); + input.files = dataTransfer.files; + } + } catch (error) { + // Compression failed — submit with original file + } + } + } + + compressImage(file) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + URL.revokeObjectURL(url); + + try { + let { width, height } = this.calculateDimensions(img.naturalWidth, img.naturalHeight); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Canvas toBlob returned null')); + return; + } + + const fileName = file.name.replace(/\.[^.]+$/, '.jpg'); + resolve(new File([blob], fileName, { + type: 'image/jpeg', + lastModified: Date.now(), + })); + }, + 'image/jpeg', + JPEG_QUALITY, + ); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); + } + + calculateDimensions(originalWidth, originalHeight) { + let width = originalWidth; + let height = originalHeight; + + if (width <= MAX_DIMENSION && height <= MAX_DIMENSION) { + return { width, height }; + } + + if (width > height) { + height = Math.round(height * (MAX_DIMENSION / width)); + width = MAX_DIMENSION; + } else { + width = Math.round(width * (MAX_DIMENSION / height)); + height = MAX_DIMENSION; + } + + return { width, height }; } disableSubmitButton() { @@ -38,4 +180,29 @@ export default class extends Controller { this.submitTarget.removeAttribute('disabled'); this.submitTarget.classList.remove('is-loading'); } + + showCompressingState() { + if (this.hasLabelTarget) { + if (this.originalLabelHtml === null) { + this.originalLabelHtml = this.labelTarget.innerHTML; + } + this.labelTarget.textContent = this.compressingTextValue; + } + } + + showSavingState() { + if (this.hasLabelTarget) { + if (this.originalLabelHtml === null) { + this.originalLabelHtml = this.labelTarget.innerHTML; + } + this.labelTarget.textContent = this.savingTextValue; + } + } + + restoreLabel() { + if (this.hasLabelTarget && this.originalLabelHtml !== null) { + this.labelTarget.innerHTML = this.originalLabelHtml; + this.originalLabelHtml = null; + } + } } diff --git a/templates/_solving_time_form.html.twig b/templates/_solving_time_form.html.twig index c10efeb3..564433cf 100644 --- a/templates/_solving_time_form.html.twig +++ b/templates/_solving_time_form.html.twig @@ -8,6 +8,9 @@ {{ form_start(solving_time_form, { 'attr': { 'data-controller': 'submit-prevention ppm-validator', + 'data-submit-prevention-compress-images-value': 'true', + 'data-submit-prevention-compressing-text-value': 'forms.compressing_images'|trans, + 'data-submit-prevention-saving-text-value': 'forms.saving'|trans, 'data-ppm-validator-active-puzzle-pieces-value': active_puzzle is defined and active_puzzle is not null ? active_puzzle.piecesCount : 0, 'data-ppm-validator-warning-too-fast-value': 'forms.ppm_warning_too_fast'|trans, 'data-ppm-validator-warning-too-slow-value': 'forms.ppm_warning_too_slow'|trans, @@ -369,7 +372,7 @@ {# PPM Warning Modal #} diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index a7d949a2..a27f4581 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -216,6 +216,7 @@ "forms": "choose_from_favorites": "- Vybrat z oblíbených -" "saving": "Ukládání..." + "compressing_images": "Komprimace fotek..." "removing": "Odebírání..." "drop_file": "Přetáhněte sem soubor pro nahrání" "choose_file": "Nebo vyberte soubor" diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 0cb77846..86242e0b 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -221,6 +221,7 @@ "close": "Schließen" "max_characters": "Maximal 500 Zeichen" "saving": "Speichern..." + "compressing_images": "Bilder werden komprimiert..." "removing": "Entfernen..." "add": "Hinzufügen" "back": "Zurück" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 32fda1ca..fc576c8e 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -264,6 +264,7 @@ forms: remove: "Remove" reset: "Reset" saving: "Saving..." + compressing_images: "Compressing images..." search: "Search" submit: "Submit" update: "Update" diff --git a/translations/messages.es.yml b/translations/messages.es.yml index f74cfc20..bf9b58bc 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -240,6 +240,7 @@ "remove": "Eliminar" "reset": "Restablecer" "saving": "Guardando..." + "compressing_images": "Comprimiendo imágenes..." "search": "Buscar" "submit": "Enviar" "update": "Actualizar" diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 6eb25312..ac431b09 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -241,6 +241,7 @@ "removing": "Suppression..." "reset": "Réinitialiser" "saving": "Sauvegarde en cours..." + "compressing_images": "Compression des images..." "search": "Rechercher" "submit": "Envoyer" "update": "Mettre à jour" diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index 24c6e614..48dc96cb 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -243,6 +243,7 @@ "required_field": "この項目は必須です!" "max_characters": "最大500文字" "saving": "保存中..." + "compressing_images": "画像を圧縮中..." "removing": "削除中..." "add_puzzle_to_collection": "choose_collection": "- コレクションを選択 -"