massive cleanup/refactor of the web UI

This commit is contained in:
xenofem 2022-05-01 02:32:56 -04:00
parent 99b93b4e7d
commit 511bd741dd
4 changed files with 304 additions and 166 deletions

View file

@ -4,12 +4,14 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" type="text/css" href="transbeam.css"/> <link rel="stylesheet" type="text/css" href="transbeam.css"/>
<link rel="stylesheet" type="text/css" href="states.css"/>
<link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/>
<link rel="manifest" href="manifest.json"/> <link rel="manifest" href="manifest.json"/>
<script src="util.js"></script> <script src="util.js"></script>
<script src="transbeam.js"></script>
<title>transbeam</title> <title>transbeam</title>
</head> </head>
<body> <body class="no_files selecting">
<div id="header"> <div id="header">
<img src="images/site-icons/transbeam.svg" height="128"> <img src="images/site-icons/transbeam.svg" height="128">
<h1>transbeam</h1> <h1>transbeam</h1>
@ -17,8 +19,12 @@
<noscript>This page requires Javascript :(</noscript> <noscript>This page requires Javascript :(</noscript>
<div id="message"></div>
<div id="upload_controls">
<div>
<button id="upload">Upload</button> <button id="upload">Upload</button>
<div id="lifetime_container" style="display: none;"> </div>
<div id="lifetime_container">
<label> <label>
Keep files for: Keep files for:
<select id="lifetime"> <select id="lifetime">
@ -29,30 +35,26 @@
</select> </select>
</label> </label>
</div> </div>
<div id="download_link_container" style="display: none;"> </div>
<div id="download_link_container">
<div id="download_link_main"> <div id="download_link_main">
<div>Download link: <span id="download_link"></span></div><div class="copy_button"></div> <div>Download link: <span id="download_link"></span></div><div class="copy_button"></div>
</div> </div>
<div id="copied_message" style="display: none;">Copied!</div> <div id="copied_message">Copied!</div>
</div> </div>
<div id="progress_container" style="display: none;"> <div id="progress_container">
<div id="progress"></div> <div id="progress"></div>
<div id="progress_bar"></div> <div id="progress_bar"></div>
</div> </div>
<div id="file_list_container" style="display: none;">
<table id="file_list"> <table id="file_list">
</table> </table>
</div> <label id="file_input_container">
<div id="file_input_container">
<label>
<input type="file" multiple id="file_input"/> <input type="file" multiple id="file_input"/>
<span class="fake_button" id="file_input_message">Select files to upload...</span> <span class="fake_button" id="file_input_message">Select files to upload...</span>
</label> </label>
</div>
<div id="footer"> <div id="footer">
<h5>(c) 2022 xenofem, MIT licensed</h5> <h5>(c) 2022 xenofem, MIT licensed</h5>
<h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5> <h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5>
</div> </div>
<script src="transbeam.js"></script>
</body> </body>
</html> </html>

39
static/states.css Normal file
View file

@ -0,0 +1,39 @@
/**
* List of classes the body can have:
*
* no_files: no files are selected
* selecting: upload hasn't started yet
* uploading: upload is in progress
* completed: upload is done
* error: an error has occurred
*/
#message { display: none; }
body.completed #message {
display: revert;
color: #060;
border-color: #0a0;
}
body.error #message {
display: revert;
color: #d00;
border-color: #f24;
}
#upload_controls { display: none; }
body.selecting #upload_controls { display: revert; }
body.no_files #upload_controls { display: none; }
body.selecting #download_link_container { display: none; }
#progress_container { display: none; }
body.uploading #progress_container { display: revert; }
body.no_files #file_list { display: none; }
body.completed #file_list { background-color: #7af; }
.delete_button { display: none; }
body.selecting .delete_button { display: revert; }
#file_input_container { display: none; }
body.selecting #file_input_container { display: revert; }

View file

@ -10,6 +10,14 @@ body {
margin-top: 5px; margin-top: 5px;
} }
#message {
border: 1px solid;
border-radius: 4px;
padding: 10px;
width: fit-content;
margin: 10px auto;
}
#progress_container { #progress_container {
margin: 10px auto; margin: 10px auto;
} }
@ -64,7 +72,15 @@ body {
bottom: 0; bottom: 0;
margin: auto; margin: auto;
height: fit-content; height: fit-content;
display: none;
} }
#download_link_container.copied #copied_message {
display: revert;
}
#download_link_container.copied #download_link_main {
visibility: hidden;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
@ -85,7 +101,7 @@ td {
padding: 10px; padding: 10px;
} }
td.file_delete { .delete_button {
background-color: #888; background-color: #888;
mask-image: url("images/feather-icons/x.svg"); mask-image: url("images/feather-icons/x.svg");
mask-size: contain; mask-size: contain;
@ -96,7 +112,7 @@ td.file_delete {
cursor: pointer; cursor: pointer;
} }
td.file_delete:hover { .delete_button:hover {
background-color: #f00; background-color: #f00;
} }
@ -127,7 +143,7 @@ button:hover, .fake_button:hover {
} }
button:disabled, input:disabled + .fake_button { button:disabled, input:disabled + .fake_button {
color: #aaa; color: #666;
background-color: #eee; background-color: #eee;
border-color: #ddd; border-color: #ddd;
cursor: not-allowed; cursor: not-allowed;

View file

@ -1,36 +1,135 @@
const FILE_CHUNK_SIZE = 16384; const FILE_CHUNK_SIZE = 16384;
const MAX_FILES = 256; const MAX_FILES = 256;
const fileInputContainer = document.getElementById('file_input_container');
const fileInput = document.getElementById('file_input');
const fileInputMessage = document.getElementById('file_input_message');
const fileListContainer = document.getElementById('file_list_container');
const fileList = document.getElementById('file_list');
const lifetimeContainer = document.getElementById('lifetime_container');
const lifetimeInput = document.getElementById('lifetime');
const uploadButton = document.getElementById('upload');
const downloadLinkContainer = document.getElementById('download_link_container');
const downloadLinkMain = document.getElementById('download_link_main');
const downloadLink = document.getElementById('download_link');
const copiedMessage = document.getElementById('copied_message');
const progressContainer = document.getElementById('progress_container');
const progress = document.getElementById('progress');
const progressBar = document.getElementById('progress_bar');
let files = []; let files = [];
let socket = null; let socket;
let fileIndex = 0; let fileIndex = 0;
let byteIndex = 0; let byteIndex = 0;
let bytesSent = 0; let bytesSent = 0;
let totalBytes = 0; let totalBytes = 0;
function sendManifest(lifetime) { let maxSize = null;
let messageBox;
let fileInput;
let fileList;
let uploadButton;
let lifetimeInput;
let downloadLink;
let progress;
let progressBar;
document.addEventListener('DOMContentLoaded', () => {
messageBox = document.getElementById('message');
fileInput = document.getElementById('file_input');
fileList = document.getElementById('file_list');
uploadButton = document.getElementById('upload');
lifetimeInput = document.getElementById('lifetime');
downloadLink = document.getElementById('download_link');
progress = document.getElementById('progress');
progressBar = document.getElementById('progress_bar');
fileInput.addEventListener('input', () => {
for (const file of fileInput.files) { addFile(file); }
updateFiles();
fileInput.value = '';
});
uploadButton.addEventListener('click', beginUpload);
const downloadLinkContainer = document.getElementById('download_link_container');
downloadLinkContainer.addEventListener('click', () => {
navigator.clipboard.writeText(downloadLink.textContent);
downloadLinkContainer.className = 'copied';
});
downloadLinkContainer.addEventListener('mouseleave', () => {
downloadLinkContainer.className = '';
});
updateFiles();
});
function updateFiles() {
const fileInputMessage = document.getElementById('file_input_message');
totalBytes = files.reduce((acc, file) => acc + file.size, 0);
fileInput.disabled = (files.length >= MAX_FILES || (maxSize !== null && totalBytes >= maxSize));
let extraClasses = '';
if (maxSize !== null && totalBytes > maxSize) {
uploadButton.disabled = true;
displayError(`The maximum size for uploads is ${displaySize(maxSize)}`);
extraClasses = ' error';
} else {
uploadButton.disabled = false;
}
if (files.length === 0) {
fileInputMessage.textContent = 'Select files to upload...';
document.body.className = 'no_files selecting' + extraClasses;
} else {
fileInputMessage.textContent = 'Select more files to upload...';
uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`;
document.body.className = 'selecting' + extraClasses;
}
}
function addFile(newFile) {
if (files.length >= MAX_FILES) { return; }
if (files.some((oldFile) => newFile.name === oldFile.name)) { return; }
files.push(newFile);
addListEntry(newFile);
}
function addListEntry(file) {
const listEntry = document.createElement('tr');
const deleteButtonCell = document.createElement('td');
deleteButtonCell.className = 'delete_button';
deleteButtonCell.addEventListener('click', () => {
removeFile(file.name);
listEntry.remove();
updateFiles();
});
const sizeCell = document.createElement('td');
sizeCell.className = 'file_size';
sizeCell.textContent = displaySize(file.size);
const nameCell = document.createElement('td');
nameCell.className = 'file_name';
nameCell.textContent = file.name;
listEntry.appendChild(deleteButtonCell);
listEntry.appendChild(sizeCell);
listEntry.appendChild(nameCell);
fileList.appendChild(listEntry);
}
function removeFile(name) {
files = files.filter((file) => file.name !== name);
}
function beginUpload() {
if (files.length === 0 || files.length > MAX_FILES) { return; }
fileIndex = 0;
byteIndex = 0;
bytesSent = 0;
socket = new WebSocket(`${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${window.location.host}/upload`);
socket.addEventListener('open', sendManifest);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose);
}
function sendManifest() {
const lifetime = parseInt(lifetimeInput.value);
const fileMetadata = files.map((file) => ({ const fileMetadata = files.map((file) => ({
name: file.name, name: file.name,
size: file.size, size: file.size,
@ -39,14 +138,55 @@ function sendManifest(lifetime) {
socket.send(JSON.stringify({ lifetime, files: fileMetadata })); socket.send(JSON.stringify({ lifetime, files: fileMetadata }));
} }
function finishSending() { function handleMessage(msg) {
if (socket.bufferedAmount > 0) { if (bytesSent === 0) {
window.setTimeout(finishSending, 1000); let reply;
try {
reply = JSON.parse(msg.data);
} catch (error) {
socket.close();
displayError('Received an invalid response from the server');
console.error(error);
return; return;
} }
if (reply.type === 'ready') {
downloadLink.textContent = `${window.location.origin}/download/${reply.code}`;
updateProgress();
document.body.className = 'uploading';
sendData();
return;
}
// we're going to display a more useful error message
socket.removeEventListener('close', handleClose);
socket.close(); socket.close();
progressContainer.textContent = "Upload complete!"; if (reply.type === 'too_big') {
fileList.style.backgroundColor = "#7af"; maxSize = reply.max_size;
updateFiles();
} else if (reply.type === 'too_long') {
let options = Array.from(lifetimeInput.options);
options.reverse();
for (const option of options) {
if (option.value > reply.max_days) {
option.disabled = true;
} else {
option.selected = true;
break;
}
}
displayError(`The maximum retention time for uploads is ${reply.max_days} days`);
} else if (reply.type === 'error') {
displayError(reply.details);
}
} else {
if (msg.data === 'ack') {
sendData();
} else {
console.error('Received unexpected message from server instead of ack', msg.data);
displayError();
socket.close();
}
}
} }
function sendData() { function sendData() {
@ -91,108 +231,49 @@ function updateProgress() {
} }
} }
function updateFiles() { function handleClose(e) {
totalBytes = files.reduce((acc, file) => acc + file.size, 0); console.log('Websocket closed', e);
if (fileIndex >= files.length) {
if (files.length === 0) { displayCompletion();
fileInputMessage.textContent = 'Select files to upload...';
fileListContainer.style.display = 'none';
uploadButton.style.display = 'none';
lifetimeContainer.style.display = 'none';
} else { } else {
fileInputMessage.textContent = 'Select more files to upload...'; let error;
fileListContainer.style.display = ''; if (e.code === 1011) {
uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`; if (e.reason) {
uploadButton.style.display = ''; error = `Server error: ${e.reason}`;
lifetimeContainer.style.display = ''; } else {
error = "A server error has occurred."
}
} else if (e.reason) {
error = e.reason;
}
displayError(error);
} }
fileInput.disabled = (files.length >= MAX_FILES);
} }
updateFiles(); function finishSending() {
downloadLinkContainer.style.display = 'none'; if (socket.bufferedAmount > 0) {
progressContainer.style.display = 'none'; window.setTimeout(finishSending, 1000);
return;
function addFile(newFile) { }
if (files.length >= MAX_FILES) { return; } socket.close();
if (files.some((oldFile) => newFile.name === oldFile.name)) { return; } displayCompletion();
files.push(newFile);
addListEntry(newFile);
} }
function addListEntry(file) { function displayCompletion() {
const listEntry = document.createElement('tr'); messageBox.textContent = 'Upload complete!';
document.body.className = 'completed';
const deleteButtonCell = document.createElement('td'); removePerFileProgressBars(); // completed file backgrounds are handled in CSS
deleteButtonCell.className = 'file_delete';
deleteButtonCell.addEventListener('click', () => {
removeFile(file.name);
listEntry.remove();
updateFiles();
});
const sizeCell = document.createElement('td');
sizeCell.className = 'file_size';
sizeCell.textContent = displaySize(file.size);
const nameCell = document.createElement('td');
nameCell.className = 'file_name';
nameCell.textContent = file.name;
listEntry.appendChild(deleteButtonCell);
listEntry.appendChild(sizeCell);
listEntry.appendChild(nameCell);
fileList.appendChild(listEntry);
} }
function removeFile(name) { function displayError(error) {
files = files.filter((file) => file.name !== name); messageBox.textContent = error || 'An error has occurred.';
document.body.className = 'selecting error';
removePerFileProgressBars();
} }
fileInput.addEventListener('input', (e) => { function removePerFileProgressBars() {
for (const file of e.target.files) { addFile(file); } const fileEntries = Array.from(fileList.children);
updateFiles(); for (entry of fileEntries) {
e.target.value = ''; entry.style.backgroundSize = "0%";
});
uploadButton.addEventListener('click', (e) => {
if (files.length === 0) { return; }
const lifetime = parseInt(lifetimeInput.value);
lifetimeContainer.remove();
fileInputContainer.remove();
for (const button of Array.from(document.getElementsByTagName('button')).concat(...document.getElementsByClassName('file_delete'))) {
button.remove();
} }
socket = new WebSocket(`${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${window.location.host}/upload`);
socket.addEventListener('open', () => sendManifest(lifetime));
socket.addEventListener('message', (msg) => {
if (bytesSent === 0) {
const reply = JSON.parse(msg.data);
if (reply.type === 'ready' && reply.code.match(/^[A-Za-z0-9]+$/)) {
downloadLink.textContent = `${window.location.origin}/download/${reply.code}`;
downloadLinkContainer.style.display = '';
updateProgress();
progressContainer.style.display = '';
sendData();
} }
} else if (msg.data === 'ack') {
sendData();
}
});
})
downloadLinkContainer.addEventListener('click', (e) => {
navigator.clipboard.writeText(downloadLink.textContent);
downloadLinkMain.style.visibility = 'hidden';
copiedMessage.style.display = '';
})
downloadLinkContainer.addEventListener('mouseleave', (e) => {
copiedMessage.style.display = 'none';
downloadLinkMain.style.visibility = 'visible';
});