massive cleanup/refactor of the web UI
This commit is contained in:
		
							parent
							
								
									99b93b4e7d
								
							
						
					
					
						commit
						511bd741dd
					
				
					 4 changed files with 304 additions and 166 deletions
				
			
		|  | @ -4,21 +4,27 @@ | |||
|     <meta charset="utf-8"/> | ||||
|     <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="states.css"/> | ||||
|     <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> | ||||
|     <link rel="manifest" href="manifest.json"/> | ||||
|     <script src="util.js"></script> | ||||
|     <script src="transbeam.js"></script> | ||||
|     <title>transbeam</title> | ||||
|   </head> | ||||
|   <body> | ||||
|       <div id="header"> | ||||
|         <img src="images/site-icons/transbeam.svg" height="128"> | ||||
|         <h1>transbeam</h1> | ||||
|   <body class="no_files selecting"> | ||||
|     <div id="header"> | ||||
|       <img src="images/site-icons/transbeam.svg" height="128"> | ||||
|       <h1>transbeam</h1> | ||||
|     </div> | ||||
| 
 | ||||
|     <noscript>This page requires Javascript :(</noscript> | ||||
| 
 | ||||
|     <div id="message"></div> | ||||
|     <div id="upload_controls"> | ||||
|       <div> | ||||
|         <button id="upload">Upload</button> | ||||
|       </div> | ||||
| 
 | ||||
|       <noscript>This page requires Javascript :(</noscript> | ||||
| 
 | ||||
|       <button id="upload">Upload</button> | ||||
|       <div id="lifetime_container" style="display: none;"> | ||||
|       <div id="lifetime_container"> | ||||
|         <label> | ||||
|           Keep files for: | ||||
|           <select id="lifetime"> | ||||
|  | @ -29,30 +35,26 @@ | |||
|           </select> | ||||
|         </label> | ||||
|       </div> | ||||
|       <div id="download_link_container" style="display: none;"> | ||||
|         <div id="download_link_main"> | ||||
|           <div>Download link: <span id="download_link"></span></div><div class="copy_button"></div> | ||||
|         </div> | ||||
|         <div id="copied_message" style="display: none;">Copied!</div> | ||||
|     </div> | ||||
|     <div id="download_link_container"> | ||||
|       <div id="download_link_main"> | ||||
|         <div>Download link: <span id="download_link"></span></div><div class="copy_button"></div> | ||||
|       </div> | ||||
|       <div id="progress_container" style="display: none;"> | ||||
|         <div id="progress"></div> | ||||
|         <div id="progress_bar"></div> | ||||
|       </div> | ||||
|       <div id="file_list_container" style="display: none;"> | ||||
|         <table id="file_list"> | ||||
|         </table> | ||||
|       </div> | ||||
|       <div id="file_input_container"> | ||||
|         <label> | ||||
|           <input type="file" multiple id="file_input"/> | ||||
|           <span class="fake_button" id="file_input_message">Select files to upload...</span> | ||||
|         </label> | ||||
|       </div> | ||||
|       <div id="footer"> | ||||
|         <h5>(c) 2022 xenofem, MIT licensed</h5> | ||||
|         <h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5> | ||||
|       </div> | ||||
|       <script src="transbeam.js"></script> | ||||
|       <div id="copied_message">Copied!</div> | ||||
|     </div> | ||||
|     <div id="progress_container"> | ||||
|       <div id="progress"></div> | ||||
|       <div id="progress_bar"></div> | ||||
|     </div> | ||||
|     <table id="file_list"> | ||||
|     </table> | ||||
|     <label id="file_input_container"> | ||||
|       <input type="file" multiple id="file_input"/> | ||||
|       <span class="fake_button" id="file_input_message">Select files to upload...</span> | ||||
|     </label> | ||||
|     <div id="footer"> | ||||
|       <h5>(c) 2022 xenofem, MIT licensed</h5> | ||||
|       <h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
|  |  | |||
							
								
								
									
										39
									
								
								static/states.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								static/states.css
									
										
									
									
									
										Normal 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; } | ||||
|  | @ -10,6 +10,14 @@ body { | |||
|     margin-top: 5px; | ||||
| } | ||||
| 
 | ||||
| #message { | ||||
|     border: 1px solid; | ||||
|     border-radius: 4px; | ||||
|     padding: 10px; | ||||
|     width: fit-content; | ||||
|     margin: 10px auto; | ||||
| } | ||||
| 
 | ||||
| #progress_container { | ||||
|     margin: 10px auto; | ||||
| } | ||||
|  | @ -64,7 +72,15 @@ body { | |||
|     bottom: 0; | ||||
|     margin: auto; | ||||
|     height: fit-content; | ||||
|     display: none; | ||||
| } | ||||
| #download_link_container.copied #copied_message { | ||||
|     display: revert; | ||||
| } | ||||
| #download_link_container.copied #download_link_main { | ||||
|     visibility: hidden; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| table { | ||||
|     border-collapse: collapse; | ||||
|  | @ -85,7 +101,7 @@ td { | |||
|     padding: 10px; | ||||
| } | ||||
| 
 | ||||
| td.file_delete { | ||||
| .delete_button { | ||||
|     background-color: #888; | ||||
|     mask-image: url("images/feather-icons/x.svg"); | ||||
|     mask-size: contain; | ||||
|  | @ -96,7 +112,7 @@ td.file_delete { | |||
|     cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| td.file_delete:hover { | ||||
| .delete_button:hover { | ||||
|     background-color: #f00; | ||||
| } | ||||
| 
 | ||||
|  | @ -127,7 +143,7 @@ button:hover, .fake_button:hover { | |||
| } | ||||
| 
 | ||||
| button:disabled, input:disabled + .fake_button { | ||||
|     color: #aaa; | ||||
|     color: #666; | ||||
|     background-color: #eee; | ||||
|     border-color: #ddd; | ||||
|     cursor: not-allowed; | ||||
|  |  | |||
|  | @ -1,36 +1,135 @@ | |||
| const FILE_CHUNK_SIZE = 16384; | ||||
| 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 socket = null; | ||||
| let socket; | ||||
| let fileIndex = 0; | ||||
| let byteIndex = 0; | ||||
| let bytesSent = 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) => ({ | ||||
|         name: file.name, | ||||
|         size: file.size, | ||||
|  | @ -39,14 +138,55 @@ function sendManifest(lifetime) { | |||
|     socket.send(JSON.stringify({ lifetime, files: fileMetadata })); | ||||
| } | ||||
| 
 | ||||
| function finishSending() { | ||||
|     if (socket.bufferedAmount > 0) { | ||||
|         window.setTimeout(finishSending, 1000); | ||||
|         return; | ||||
| function handleMessage(msg) { | ||||
|     if (bytesSent === 0) { | ||||
|         let reply; | ||||
|         try { | ||||
|             reply = JSON.parse(msg.data); | ||||
|         } catch (error) { | ||||
|             socket.close(); | ||||
|             displayError('Received an invalid response from the server'); | ||||
|             console.error(error); | ||||
|             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(); | ||||
|         if (reply.type === 'too_big') { | ||||
|             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(); | ||||
|         } | ||||
|     } | ||||
|     socket.close(); | ||||
|     progressContainer.textContent = "Upload complete!"; | ||||
|     fileList.style.backgroundColor = "#7af"; | ||||
| } | ||||
| 
 | ||||
| function sendData() { | ||||
|  | @ -91,108 +231,49 @@ function updateProgress() { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| function updateFiles() { | ||||
|     totalBytes = files.reduce((acc, file) => acc + file.size, 0); | ||||
| 
 | ||||
|     if (files.length === 0) { | ||||
|         fileInputMessage.textContent = 'Select files to upload...'; | ||||
|         fileListContainer.style.display = 'none'; | ||||
|         uploadButton.style.display = 'none'; | ||||
|         lifetimeContainer.style.display = 'none'; | ||||
| function handleClose(e) { | ||||
|     console.log('Websocket closed', e); | ||||
|     if (fileIndex >= files.length) { | ||||
|         displayCompletion(); | ||||
|     } else { | ||||
|         fileInputMessage.textContent = 'Select more files to upload...'; | ||||
|         fileListContainer.style.display = ''; | ||||
|         uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`; | ||||
|         uploadButton.style.display = ''; | ||||
|         lifetimeContainer.style.display = ''; | ||||
|     } | ||||
|     fileInput.disabled = (files.length >= MAX_FILES); | ||||
| } | ||||
| 
 | ||||
| updateFiles(); | ||||
| downloadLinkContainer.style.display = 'none'; | ||||
| progressContainer.style.display = 'none'; | ||||
| 
 | ||||
| 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 = '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) { | ||||
|     files = files.filter((file) => file.name !== name); | ||||
| } | ||||
| 
 | ||||
| fileInput.addEventListener('input', (e) => { | ||||
|     for (const file of e.target.files) { addFile(file); } | ||||
|     updateFiles(); | ||||
|     e.target.value = ''; | ||||
| }); | ||||
| 
 | ||||
| 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(); | ||||
|         let error; | ||||
|         if (e.code === 1011) { | ||||
|             if (e.reason) { | ||||
|                 error = `Server error: ${e.reason}`; | ||||
|             } else { | ||||
|                 error = "A server error has occurred." | ||||
|             } | ||||
|         } else if (msg.data === 'ack') { | ||||
|             sendData(); | ||||
|         } else if (e.reason) { | ||||
|             error = e.reason; | ||||
|         } | ||||
|     }); | ||||
| }) | ||||
|         displayError(error); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| downloadLinkContainer.addEventListener('click', (e) => { | ||||
|     navigator.clipboard.writeText(downloadLink.textContent); | ||||
|     downloadLinkMain.style.visibility = 'hidden'; | ||||
|     copiedMessage.style.display = ''; | ||||
| }) | ||||
| function finishSending() { | ||||
|     if (socket.bufferedAmount > 0) { | ||||
|         window.setTimeout(finishSending, 1000); | ||||
|         return; | ||||
|     } | ||||
|     socket.close(); | ||||
|     displayCompletion(); | ||||
| } | ||||
| 
 | ||||
| downloadLinkContainer.addEventListener('mouseleave', (e) => { | ||||
|     copiedMessage.style.display = 'none'; | ||||
|     downloadLinkMain.style.visibility = 'visible'; | ||||
| }); | ||||
| function displayCompletion() { | ||||
|     messageBox.textContent = 'Upload complete!'; | ||||
|     document.body.className = 'completed'; | ||||
|     removePerFileProgressBars(); // completed file backgrounds are handled in CSS
 | ||||
| } | ||||
| 
 | ||||
| function displayError(error) { | ||||
|     messageBox.textContent = error || 'An error has occurred.'; | ||||
|     document.body.className = 'selecting error'; | ||||
|     removePerFileProgressBars(); | ||||
| } | ||||
| 
 | ||||
| function removePerFileProgressBars() { | ||||
|     const fileEntries = Array.from(fileList.children); | ||||
|     for (entry of fileEntries) { | ||||
|         entry.style.backgroundSize = "0%"; | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue