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:
1 parent
9cb4021229
commit
58ecfa0dc3
14 files changed
+1824
-15
No files matched your search
+202
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user