Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 173 additions & 6 deletions assets/controllers/submit_prevention_controller.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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() {
Expand All @@ -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;
}
}
}
5 changes: 4 additions & 1 deletion templates/_solving_time_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -369,7 +372,7 @@

<button type="submit" name="submit" class="btn btn-primary w-100" data-submit-prevention-target="submit">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<i class="ci-check me-1"></i> {{ 'forms.save'|trans }}
<span data-submit-prevention-target="label"><i class="ci-check me-1"></i> {{ 'forms.save'|trans }}</span>
</button>

{# PPM Warning Modal #}
Expand Down
1 change: 1 addition & 0 deletions translations/messages.cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions translations/messages.de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions translations/messages.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ forms:
remove: "Remove"
reset: "Reset"
saving: "Saving..."
compressing_images: "Compressing images..."
search: "Search"
submit: "Submit"
update: "Update"
Expand Down
1 change: 1 addition & 0 deletions translations/messages.es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@
"remove": "Eliminar"
"reset": "Restablecer"
"saving": "Guardando..."
"compressing_images": "Comprimiendo imágenes..."
"search": "Buscar"
"submit": "Enviar"
"update": "Actualizar"
Expand Down
1 change: 1 addition & 0 deletions translations/messages.fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions translations/messages.ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"required_field": "この項目は必須です!"
"max_characters": "最大500文字"
"saving": "保存中..."
"compressing_images": "画像を圧縮中..."
"removing": "削除中..."
"add_puzzle_to_collection":
"choose_collection": "- コレクションを選択 -"
Expand Down
Loading