aboutsummaryrefslogtreecommitdiff
path: root/themes/alps
diff options
context:
space:
mode:
authorDrew DeVault <sir@cmpwn.com>2020-10-29 15:18:36 -0400
committerDrew DeVault <sir@cmpwn.com>2020-10-29 15:18:36 -0400
commita393429f01e63aa37f58f8cbe4a810e59852fa61 (patch)
treece24cbc869e3cdda0b13e9fa3d9e34ff192c868a /themes/alps
parent490420726952bb3834e6a1cdda7a26c90ba9a7cb (diff)
downloadalps-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.js146
-rw-r--r--themes/alps/assets/style.css96
-rw-r--r--themes/alps/compose.html41
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 = "&times";
+ 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>&times;</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"}}