A file past the size limit makes the multipart stream itself throw, which escaped the per-file catch and reached the error handler: the phone that just sent a 35 MB photo got 'Something went wrong'. Catch the limit errors around the part loop, say which limit was hit, and report it once rather than twice. Also asserts that derivatives carry no EXIF while the Original keeps the uploaded bytes.
208 lines
6.6 KiB
JavaScript
208 lines
6.6 KiB
JavaScript
/* Admin enhancements. Every action here also has a form-post equivalent, so
|
|
losing this file costs convenience, not function. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var root = document.querySelector('[data-set-id]');
|
|
if (!root) return;
|
|
|
|
var setId = root.getAttribute('data-set-id');
|
|
var csrf = root.getAttribute('data-csrf');
|
|
var MAX_EDGE = 3000;
|
|
var JPEG_QUALITY = 0.92;
|
|
|
|
var drop = document.querySelector('[data-drop]');
|
|
var fileInput = document.querySelector('[data-file-input]');
|
|
var status = document.querySelector('[data-upload-status]');
|
|
var choose = document.querySelector('[data-choose]');
|
|
var list = document.querySelector('[data-pages-admin]');
|
|
var noJsForm = document.querySelector('.no-js-upload');
|
|
|
|
if (noJsForm) noJsForm.style.display = 'none';
|
|
|
|
Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (form) {
|
|
form.addEventListener('submit', function (event) {
|
|
if (!window.confirm(form.getAttribute('data-confirm'))) event.preventDefault();
|
|
});
|
|
});
|
|
|
|
function say(message) {
|
|
if (status) status.textContent = message;
|
|
}
|
|
|
|
/* Shrink in the browser so a 20-page menu is quick to upload over restaurant
|
|
wifi. Photos the browser cannot decode (HEIC, say) go up untouched and are
|
|
rejected later with a message naming the file. */
|
|
function shrink(file) {
|
|
return new Promise(function (resolve) {
|
|
if (!/^image\/(jpeg|png|webp)$/.test(file.type) || typeof createImageBitmap !== 'function') {
|
|
return resolve(file);
|
|
}
|
|
createImageBitmap(file)
|
|
.then(function (bitmap) {
|
|
var scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
if (scale === 1 && file.type === 'image/jpeg' && file.size < 4 * 1024 * 1024) {
|
|
bitmap.close();
|
|
return resolve(file);
|
|
}
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = Math.round(bitmap.width * scale);
|
|
canvas.height = Math.round(bitmap.height * scale);
|
|
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
bitmap.close();
|
|
canvas.toBlob(
|
|
function (blob) {
|
|
if (!blob || blob.size >= file.size) return resolve(file);
|
|
var name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
|
|
resolve(new File([blob], name, { type: 'image/jpeg' }));
|
|
},
|
|
'image/jpeg',
|
|
JPEG_QUALITY,
|
|
);
|
|
})
|
|
.catch(function () {
|
|
resolve(file);
|
|
});
|
|
});
|
|
}
|
|
|
|
function uploadOne(file) {
|
|
var body = new FormData();
|
|
body.append('pages', file, file.name);
|
|
return fetch('/admin/sets/' + setId + '/pages', {
|
|
method: 'POST',
|
|
headers: { 'x-csrf': csrf, accept: 'application/json' },
|
|
body: body,
|
|
}).then(function (response) {
|
|
return response.json().then(function (payload) {
|
|
// The server reports per-file problems in `failed`, including on a 413,
|
|
// so those messages win over a generic fallback.
|
|
var detail = (payload.failed || [])
|
|
.map(function (item) {
|
|
return item.error;
|
|
})
|
|
.join('; ');
|
|
if (!response.ok) throw new Error(detail || payload.error || 'upload failed');
|
|
if (detail) throw new Error(detail);
|
|
return payload;
|
|
});
|
|
});
|
|
}
|
|
|
|
function upload(files) {
|
|
var queue = Array.prototype.slice.call(files);
|
|
if (queue.length === 0) return;
|
|
var failures = [];
|
|
|
|
function next(index) {
|
|
if (index >= queue.length) {
|
|
if (failures.length === 0) {
|
|
say('Uploaded ' + queue.length + '. Reloading…');
|
|
window.location.reload();
|
|
} else {
|
|
say('Failed: ' + failures.join('; '));
|
|
}
|
|
return;
|
|
}
|
|
say('Shrinking and uploading ' + (index + 1) + ' of ' + queue.length + '…');
|
|
shrink(queue[index])
|
|
.then(uploadOne)
|
|
.then(function () {
|
|
next(index + 1);
|
|
})
|
|
.catch(function (error) {
|
|
failures.push(error.message);
|
|
next(index + 1);
|
|
});
|
|
}
|
|
|
|
next(0);
|
|
}
|
|
|
|
if (choose && fileInput) {
|
|
choose.addEventListener('click', function () {
|
|
fileInput.click();
|
|
});
|
|
}
|
|
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', function () {
|
|
upload(fileInput.files);
|
|
});
|
|
}
|
|
|
|
if (drop) {
|
|
['dragenter', 'dragover'].forEach(function (type) {
|
|
drop.addEventListener(type, function (event) {
|
|
event.preventDefault();
|
|
drop.classList.add('is-over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(function (type) {
|
|
drop.addEventListener(type, function (event) {
|
|
event.preventDefault();
|
|
drop.classList.remove('is-over');
|
|
});
|
|
});
|
|
drop.addEventListener('drop', function (event) {
|
|
if (event.dataTransfer && event.dataTransfer.files) upload(event.dataTransfer.files);
|
|
});
|
|
}
|
|
|
|
/* Drag to reorder. The up/down buttons remain for touch and no-JS. */
|
|
function items() {
|
|
return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]'));
|
|
}
|
|
|
|
function renumber() {
|
|
items().forEach(function (item, index) {
|
|
var number = item.querySelector('.page-admin__number');
|
|
if (number) number.textContent = String(index + 1);
|
|
});
|
|
}
|
|
|
|
function saveOrder() {
|
|
var ids = items().map(function (item) {
|
|
return item.getAttribute('data-page-id');
|
|
});
|
|
say('Saving order…');
|
|
return fetch('/admin/sets/' + setId + '/reorder', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' },
|
|
body: JSON.stringify({ pageIds: ids }),
|
|
})
|
|
.then(function (response) {
|
|
if (!response.ok) throw new Error('could not save the order');
|
|
say('Order saved.');
|
|
})
|
|
.catch(function (error) {
|
|
say(error.message);
|
|
});
|
|
}
|
|
|
|
if (list) {
|
|
var dragged = null;
|
|
|
|
items().forEach(function (item) {
|
|
item.addEventListener('dragstart', function (event) {
|
|
dragged = item;
|
|
item.classList.add('is-dragging');
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
item.addEventListener('dragend', function () {
|
|
item.classList.remove('is-dragging');
|
|
dragged = null;
|
|
renumber();
|
|
saveOrder();
|
|
});
|
|
item.addEventListener('dragover', function (event) {
|
|
if (!dragged || dragged === item) return;
|
|
event.preventDefault();
|
|
var rect = item.getBoundingClientRect();
|
|
var after = event.clientY > rect.top + rect.height / 2;
|
|
list.insertBefore(dragged, after ? item.nextSibling : item);
|
|
});
|
|
});
|
|
}
|
|
})();
|