From a393429f01e63aa37f58f8cbe4a810e59852fa61 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Thu, 29 Oct 2020 15:18:36 -0400 Subject: Implement JavaScript UI for attachments This one is a bit of a doozy. A summary of the changes: - Session has grown storage for attachments which have been uploaded but not yet sent. - The list of attachments on a message is refcounted so that we can clean up the temporary files only after it's done with - i.e. after copying to Sent and after all of the SMTP attempts are done. - Abandoned attachments are cleared out on process shutdown. Future work: - Add a limit to the maximum number of pending attachments the user can have in the session. - Periodically clean out abandoned attachments? --- themes/alps/assets/attachments.js | 146 ++++++++++++++++++++++++++++++++++++++ themes/alps/assets/style.css | 96 ++++++++++++++++++++++--- themes/alps/compose.html | 41 +++++++---- 3 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 themes/alps/assets/attachments.js (limited to 'themes') diff --git a/themes/alps/assets/attachments.js b/themes/alps/assets/attachments.js new file mode 100644 index 0000000..bd8c78b --- /dev/null +++ b/themes/alps/assets/attachments.js @@ -0,0 +1,146 @@ +let attachments = []; + +const headers = document.querySelector(".create-update .headers"); +headers.classList.remove("no-js"); + +const attachmentsNode = document.getElementById("attachment-list"); +attachmentsNode.style.display = ''; +const helpNode = attachmentsNode.querySelector(".help"); + +const attachmentsInput = headers.querySelector("input[type='file']"); +attachmentsInput.removeAttribute("name"); +attachmentsInput.addEventListener("input", ev => { + const files = attachmentsInput.files; + for (let i = 0; i < files.length; i++) { + attachFile(files[i]); + } +}); + +document.body.addEventListener("drop", ev => { + ev.preventDefault(); + const files = ev.dataTransfer.files; + for (let i = 0; i < files.length; i++) { + attachFile(files[i]); + } +}); + +const sendButton = document.getElementById("send-button"), + saveButton = document.getElementById("save-button"); + +const XHR_UNSENT = 0, + XHR_OPENED = 1, + XHR_HEADERS_RECEIVED = 2, + XHR_LOADING = 3, + XHR_DONE = 4; + +const attachmentUUIDsNode = document.getElementById("attachment-uuids"); +function updateState() { + let complete = true; + for (let i = 0; i < attachments.length; i++) { + const a = attachments[i]; + const progress = a.node.querySelector(".progress"); + progress.style.width = `${Math.floor(a.progress * 100)}%`; + complete &= a.progress === 1.0; + if (a.progress === 1.0) { + progress.style.display = 'none'; + } + } + + if (complete) { + sendButton.removeAttribute("disabled"); + saveButton.removeAttribute("disabled"); + } else { + sendButton.setAttribute("disabled", "disabled"); + saveButton.setAttribute("disabled", "disabled"); + } + + attachmentUUIDsNode.value = attachments. + filter(a => a.progress === 1.0). + map(a => a.uuid). + join(","); +} + +function attachFile(file) { + helpNode.remove(); + + const xhr = new XMLHttpRequest(); + const node = attachmentNodeFor(file); + const attachment = { + node: node, + progress: 0, + xhr: xhr, + }; + attachments.push(attachment); + attachmentsNode.appendChild(node); + + let formData = new FormData(); + formData.append("attachments", file); + + xhr.open("POST", "/compose/attachment"); + xhr.upload.addEventListener("progress", ev => { + attachment.progress = ev.loaded / ev.total; + updateState(); + }); + xhr.addEventListener("load", () => { + // TODO: Handle errors + const resp = JSON.parse(xhr.responseText); + attachment.uuid = resp[0]; + updateState(); + }); + xhr.send(formData); + + updateState(); +} + +function attachmentNodeFor(file) { + const node = document.createElement("div"), + progress = document.createElement("span"), + filename = document.createElement("span"), + size = document.createElement("span"), + button = document.createElement("button"); + node.classList.add("upload"); + + progress.classList.add("progress"); + node.appendChild(progress); + + filename.classList.add("filename"); + filename.innerText = file.name; + node.appendChild(filename); + + size.classList.add("size"); + size.innerText = formatSI(file.size) + "B"; + node.appendChild(size); + + button.innerHTML = "×"; + node.appendChild(button); + return node; +} + +// via https://github.com/ThomWright/format-si-prefix; MIT license +// Copyright (c) 2015 Thom Wright +const PREFIXES = { + '24': 'Y', '21': 'Z', '18': 'E', '15': 'P', '12': 'T', '9': 'G', '6': 'M', + '3': 'k', '0': '', '-3': 'm', '-6': 'ยต', '-9': 'n', '-12': 'p', '-15': 'f', + '-18': 'a', '-21': 'z', '-24': 'y' +}; + +function formatSI(num) { + if (num === 0) { + return '0'; + } + let sig = Math.abs(num); // significand + let exponent = 0; + while (sig >= 1000 && exponent < 24) { + sig /= 1000; + exponent += 3; + } + while (sig < 1 && exponent > -24) { + sig *= 1000; + exponent -= 3; + } + const signPrefix = num < 0 ? '-' : ''; + if (sig > 1000) { + return signPrefix + sig.toFixed(0) + PREFIXES[exponent]; + } + return signPrefix + parseFloat(sig.toPrecision(3)) + PREFIXES[exponent]; +} diff --git a/themes/alps/assets/style.css b/themes/alps/assets/style.css index 77aaf3c..389079c 100644 --- a/themes/alps/assets/style.css +++ b/themes/alps/assets/style.css @@ -166,19 +166,89 @@ main.create-update { } main.create-update { flex: 1 auto; padding: 1rem; } -main.create-update form { flex: 1 auto; display: flex; flex-direction: column; } -main.create-update form label { margin-top: 5px; } +main.create-update form { + flex: 1 auto; + display: flex; + flex-direction: column; +} -/* TODO: CSS grid this */ -main.create-update form label span { - display: inline-block; - font-weight: bold; - min-width: 150px; +main.create-update .headers { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto auto auto; + grid-gap: 0.5rem; + align-items: center; +} + +main.create-update .headers.no-js { + grid-template-columns: auto 1fr; +} + +main.create-update .headers label { + grid-column-start: 1; +} + +main.create-update .headers input { + grid-column-start: 2; + grid-column-end: 3; +} + +main.create-update #attachment-list { + grid-column-start: 3; + grid-row-start: 1; + grid-row-end: 5; + + width: 25rem; + height: 100%; + background: #eee; + overflow-y: scroll; + border: 1px solid #eee; + + display: flex; + flex-direction: column; +} + +main.create-update #attachment-list .help { + text-align: center; + color: #555; + margin-top: 1rem; } -main.create-update form input { width: 80%; } -main.create-update form textarea { flex: 1 auto; resize: none; margin-top: 1rem; } -main.create-update h1 { margin: 0; } +main.create-update #attachment-list .upload { + width: calc(100% - 1rem); + position: relative; + display: flex; + margin: 0.5rem; + padding: 0.25rem 0.5rem; + background: white; + align-items: center; +} + +main.create-update #attachment-list *:not(:last-child) { + margin-right: 0.25rem; +} + +main.create-update #attachment-list .upload .filename { + flex-grow: 1; +} + +main.create-update #attachment-list .upload button { + padding: inherit; +} + +main.create-update #attachment-list .upload .progress { + position: absolute; + height: 5px; + background: #50C878; + bottom: 0; + left: 0; +} + +main.create-update textarea { + flex: 1 auto; + resize: none; + margin-top: 1rem; +} main table { border-collapse: collapse; width: 100%; border: 1px solid #eee; } main table td { @@ -683,6 +753,12 @@ button:hover, text-decoration: none; } +button[disabled], button[disabled]:hover { + color: #555; + background-color: #c5c5c5; + cursor: default; +} + .button:active, button:active, .button-link:active { diff --git a/themes/alps/compose.html b/themes/alps/compose.html index fe3c86a..5874748 100644 --- a/themes/alps/compose.html +++ b/themes/alps/compose.html @@ -8,15 +8,14 @@
-
+ -
- + {{template "foot.html"}} -- cgit v1.2.3