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