diff options
author | Drew DeVault <sir@cmpwn.com> | 2020-10-29 15:18:36 -0400 |
---|---|---|
committer | Drew DeVault <sir@cmpwn.com> | 2020-10-29 15:18:36 -0400 |
commit | a393429f01e63aa37f58f8cbe4a810e59852fa61 (patch) | |
tree | ce24cbc869e3cdda0b13e9fa3d9e34ff192c868a /themes/alps | |
parent | 490420726952bb3834e6a1cdda7a26c90ba9a7cb (diff) | |
download | alps-a393429f01e63aa37f58f8cbe4a810e59852fa61.tar.gz alps-a393429f01e63aa37f58f8cbe4a810e59852fa61.zip |
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?
Diffstat (limited to 'themes/alps')
-rw-r--r-- | themes/alps/assets/attachments.js | 146 | ||||
-rw-r--r-- | themes/alps/assets/style.css | 96 | ||||
-rw-r--r-- | themes/alps/compose.html | 41 |
3 files changed, 261 insertions, 22 deletions
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 @@ <div class="container"> <main class="create-update"> - <form method="post" action="" enctype="multipart/form-data"> + <form method="post" enctype="multipart/form-data"> <input type="hidden" name="in_reply_to" value="{{.Message.InReplyTo}}"> - <label> - <span>From</span> + <div class="headers no-js"> + <label>From</label> <input type="email" name="from" id="from" value="{{.Message.From}}" /> - </label> - <label> - <span>To</span> + + <label>To</label> <input type="email" name="to" @@ -26,9 +25,27 @@ list="emails" {{ if not .Message.To }} autofocus{{ end }} /> - </label> - <label><span>Subject</span><input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/></label> - <label><span>Attachments</span><input type="file" name="attachments" id="attachments" multiple></label> + + <label>Subject</label> + <input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/> + + <label>Attachments</label> + <input type="file" name="attachments" id="attachments" multiple> + + <div id="attachment-list" style="display: none;"> + <div class="help">Drag and drop attachments here</div> + <!-- + <div class="upload"> + <span class="progress"></span> + <span class="filename">foobar.pdf</span> + <span class="size">1234 KiB</span> + <button>×</button> + </div> + --> + </div> + + <input type="hidden" id="attachment-uuids" name="attachment-uuids" value="" /> + </div> <!-- TODO: list of previous attachments (needs design) --> <textarea name="text" class="body">{{.Message.Text}}</textarea> @@ -40,8 +57,8 @@ </datalist> <div class="actions"> - <button type="submit">Send Message</button> - <button type="submit" name="save_as_draft">Save as draft</button> + <button id="send-button" type="submit">Send Message</button> + <button id="save-button" type="submit" name="save_as_draft">Save as draft</button> <a class="button-link" href="/mailbox/INBOX">Cancel</a> </div> </form> @@ -49,6 +66,6 @@ </main> </div> </div> - +<script src="/themes/alps/assets/attachments.js"></script> {{template "foot.html"}} |