Two defects, both found by driving the real editor in Firefox: 1. The editor drew grab handles with a grab cursor on sets it refuses to reorder, so the live Food menu looked draggable and did nothing. Handles and the move buttons are now rendered only when reordering is possible, and the notice says what can still be done. 2. Reordering was saved only from dragend. Browsers suppress mouse events during a drag and dragend is not guaranteed to arrive, which left the rows visibly reordered while the server kept the old order — the drag looked like it worked and then reverted on reload. The order now commits from dragover after a pause in the drag, with drop/dragend/mouse events as faster paths, deduped against the last confirmed order and reported in the page. To make (1) coherent the Current set may now be reordered while its Pages stay fixed: order is presentation, media URLs are content-hashed and pages are rendered per request, so nothing stale can be served (ADR 0006). Archives stay frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like this one is not masked by a stale admin.js. scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it is the only automated check that reaches this path.
250 lines
8.1 KiB
JavaScript
250 lines
8.1 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 order() {
|
|
return items().map(function (item) {
|
|
return item.getAttribute('data-page-id');
|
|
});
|
|
}
|
|
|
|
function renumber() {
|
|
items().forEach(function (item, index) {
|
|
var number = item.querySelector('.page-admin__number');
|
|
if (number) number.textContent = String(index + 1);
|
|
});
|
|
}
|
|
|
|
// The order the server last confirmed. Everything below goes through
|
|
// saveOrderIfChanged, so a reorder is sent once however the drag ends.
|
|
var savedOrder = list ? order().join(',') : '';
|
|
var orderStatus = document.querySelector('[data-order-status]');
|
|
|
|
function sayOrder(message) {
|
|
if (orderStatus) orderStatus.textContent = message;
|
|
}
|
|
|
|
function saveOrderIfChanged() {
|
|
if (!list) return;
|
|
var ids = order();
|
|
var key = ids.join(',');
|
|
if (key === savedOrder) return;
|
|
sayOrder('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');
|
|
savedOrder = key;
|
|
sayOrder('Order saved.');
|
|
})
|
|
.catch(function (error) {
|
|
// Leave savedOrder alone so the next interaction tries again, and say so
|
|
// rather than leaving a reordered list that is not what customers see.
|
|
sayOrder(error.message + ' — not saved yet, try again');
|
|
});
|
|
}
|
|
|
|
if (list) {
|
|
var dragged = null;
|
|
var pendingSave = null;
|
|
|
|
// Browsers suppress mouse events while a drag is in flight, and dragend is
|
|
// not guaranteed to arrive — so the save cannot wait for the drag to end.
|
|
// The rows have already moved by then, and what is on screen is what should
|
|
// be saved, so a pause in the drag commits it.
|
|
function scheduleSave() {
|
|
if (pendingSave) clearTimeout(pendingSave);
|
|
pendingSave = setTimeout(function () {
|
|
pendingSave = null;
|
|
saveOrderIfChanged();
|
|
}, 600);
|
|
}
|
|
|
|
function commitSave() {
|
|
if (pendingSave) {
|
|
clearTimeout(pendingSave);
|
|
pendingSave = null;
|
|
}
|
|
renumber();
|
|
saveOrderIfChanged();
|
|
}
|
|
|
|
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', commitSave);
|
|
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);
|
|
renumber();
|
|
scheduleSave();
|
|
});
|
|
});
|
|
|
|
['drop', 'pointerup', 'mouseup', 'pointercancel'].forEach(function (type) {
|
|
list.addEventListener(type, commitSave);
|
|
});
|
|
}
|
|
})();
|