Publish menus from the browser instead of scp

The admin signs in with the shared password, creates a Menu Set, drops photos
into it (shrunk in the browser first), reorders Pages by dragging or with
up/down buttons, previews the Draft exactly as customers would see it, and
publishes it over the previous set. Restore and delete close the loop. Mutations
need a session, a same-origin request and a CSRF token derived from the session;
a Draft's media is served only to a session holder.
This commit is contained in:
tanshu committed 2026-09-22 21:22:26 +05:30
1 parent 9cb4021229
commit 58ecfa0dc3
14 files changed
+1824 -15

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
/* 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) {
if (!response.ok) throw new Error(payload.error || 'upload failed');
if (payload.failed && payload.failed.length > 0) {
throw new Error(payload.failed.map(function (item) { return item.error; }).join('; '));
}
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);
});
});
}
})();