Files
menu/public/admin.js
T
tanshu a635c5a214 Answer an oversized photo with the reason, not a 500
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.
2026-09-22 21:41:12 +05:30

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);
});
});
}
})();