From be81eaefa526fa80e04166e86978e3a95263b4e3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Thu, 6 Jun 2024 18:42:20 -0400 Subject: feat: Added HTML and Javascript progress bar when uploading files --- src/renderer.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 8 deletions(-) (limited to 'src/renderer.rs') diff --git a/src/renderer.rs b/src/renderer.rs index 3935d98..6c1f393 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -53,7 +53,7 @@ pub fn page( html! { (DOCTYPE) html { - (page_header(&title_path, conf.file_upload, &conf.favicon_route, &conf.css_route)) + (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) body #drop-container { @@ -175,6 +175,37 @@ pub fn page( } } } + div.upload_area id="upload_area" { + template id="upload_file_item" { + li.upload_file_item { + div.upload_file_container { + div.upload_file_text { + span.file_upload_percent { "" } + {" - "} + span.file_size { "" } + {" - "} + span.file_name { "" } + } + button.file_cancel_upload { "✖" } + } + div.file_progress_bar {} + } + } + div.upload_container { + div.upload_header { + h4 style="margin:0px" id="upload_title" {} + } + div.upload_action { + p id="upload_action_text" { "Starting upload..." } + button.upload_cancel id="upload_cancel" { "CANCEL" } + } + div.upload_files { + ul.upload_file_list id="upload_file_list" { + + } + } + } + } } } } @@ -571,7 +602,7 @@ fn chevron_down() -> Markup { } /// Partial: page header -fn page_header(title: &str, file_upload: bool, favicon_route: &str, css_route: &str) -> Markup { +fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favicon_route: &str, css_route: &str) -> Markup { html! { head { meta charset="utf-8"; @@ -612,9 +643,23 @@ fn page_header(title: &str, file_upload: bool, favicon_route: &str, css_route: & "#)) @if file_upload { - (PreEscaped(r#" - - "#)) + "#)) + } } } } @@ -677,7 +904,7 @@ pub fn render_error( html! { (DOCTYPE) html { - (page_header(&error_code.to_string(), false, &conf.favicon_route, &conf.css_route)) + (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) body { -- cgit v1.2.3 From 1d8b6d8f1f45047e2908506c490d175a8c0a65aa Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Thu, 6 Jun 2024 19:08:47 -0400 Subject: chore: clean up --- src/renderer.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'src/renderer.rs') diff --git a/src/renderer.rs b/src/renderer.rs index 6c1f393..9af601c 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -702,8 +702,8 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadFiles() }) - const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; function updateUploadText() { + const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; const total = document.querySelectorAll("[data-state]").length; const uploads = queryLength(UPLOADING); const pending = queryLength(PENDING); @@ -731,7 +731,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi // Update list of uploads Array.from(uploadList.querySelectorAll('li')) - .sort(({dataset: { state: a }}, {dataset: { state: b}}) => UPLOAD_ITEM_ORDER[a] >= UPLOAD_ITEM_ORDER[b]) + .sort(({ dataset: { state: a }}, {dataset: { state: b }}) => UPLOAD_ITEM_ORDER[a] >= UPLOAD_ITEM_ORDER[b]) .forEach((item) => item.parentNode.appendChild(item)) } @@ -748,7 +748,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const iterator = callbacks.entries(); const concurrency = CONCURRENCY === 0 ? callbacks.length : CONCURRENCY; const workers = Array(concurrency).fill(iterator).map(doWork) - Promise.allSettled(workers).then(console.log.bind(null, 'done')) + Promise.allSettled(workers) .finally(() => { updateUploadText(); form.reset(); @@ -794,7 +794,6 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi formData.append('file', file); function onReadyStateChange(e) { - console.log('readystatechange', e) if (e.target.readyState == 4) { if (e.target.status == 200) { completeSuccess() -- cgit v1.2.3 From 413a63a60307bdf60229670b0f858963604d62a3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Sun, 16 Feb 2025 23:35:26 -0500 Subject: feat: implement temporary file uploads and tweak mobile design --- src/renderer.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 13 deletions(-) (limited to 'src/renderer.rs') diff --git a/src/renderer.rs b/src/renderer.rs index 035309d..8a87228 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -193,6 +193,9 @@ pub fn page( div.upload_container { div.upload_header { h4 style="margin:0px" id="upload_title" {} + svg id="upload-toggle" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6" { + path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" {} + } } div.upload_action { p id="upload_action_text" { "Starting upload..." } @@ -663,6 +666,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const uploadCancelButton = document.querySelector('#upload_cancel'); const uploadList = document.querySelector('#upload_file_list'); const fileUploadItemTemplate = document.querySelector('#upload_file_item'); + const uploadWidgetToggle = document.querySelector('#upload-toggle'); const dropContainer = document.querySelector('#drop-container'); const dragForm = document.querySelector('.drag-form'); @@ -696,6 +700,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi dragForm.style.display = 'none'; }; + uploadWidgetToggle.addEventListener('click', function (e) { + e.preventDefault(); + if (uploadArea.style.height === "100vh") { + uploadArea.style = "" + document.body.style = "" + uploadWidgetToggle.style = "" + } else { + uploadArea.style.height = "100vh" + document.body.style = "overflow: hidden" + uploadWidgetToggle.style = "transform: rotate(180deg)" + } + }) + uploadCancelButton.addEventListener('click', function (e) { e.preventDefault(); CANCEL_UPLOAD = true; @@ -706,7 +723,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadFiles() }) - function updateUploadText() { + function updateUploadTextAndList() { const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; const total = document.querySelectorAll("[data-state]").length; const uploads = queryLength(UPLOADING); @@ -733,16 +750,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadTitle.textContent = headerText uploadActionText.textContent = statuses.join(', ') - // Update list of uploads - Array.from(uploadList.querySelectorAll('li')) - .sort(({ dataset: { state: a }}, {dataset: { state: b }}) => UPLOAD_ITEM_ORDER[a] >= UPLOAD_ITEM_ORDER[b]) - .forEach((item) => item.parentNode.appendChild(item)) + const items = Array.from(uploadList.querySelectorAll('li')); + items.sort((a, b) => UPLOAD_ITEM_ORDER[a.dataset.state] - UPLOAD_ITEM_ORDER[b.dataset.state]); + items.forEach((item, index) => { + if (uploadList.children[index] !== item) { + uploadList.insertBefore(item, uploadList.children[index]); + } + }); } async function doWork(iterator, i) { for (let [index, item] of iterator) { await item(); - updateUploadText(); + updateUploadTextAndList(); } } @@ -754,17 +774,17 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const workers = Array(concurrency).fill(iterator).map(doWork) Promise.allSettled(workers) .finally(() => { - updateUploadText(); + updateUploadTextAndList(); form.reset(); setTimeout(() => { uploadArea.classList.remove('active'); }, 1000) setTimeout(() => { window.location.reload(); }, 1500) }) - updateUploadText(); + updateUploadTextAndList(); uploadArea.classList.add('active') uploadList.scrollTo(0, 0) } - + function formatBytes(bytes, decimals) { if (bytes == 0) return '0 Bytes'; var k = 1024, @@ -774,6 +794,19 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } + document.querySelector('input[type="file"]').addEventListener('change', async (e) => { + const file = e.target.files[0]; + const hash = await hashFile(file); + console.log('File hash:', hash); + }); + + async function get256FileHash(file) { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } + function uploadFile(file) { const fileUploadItem = fileUploadItemTemplate.content.cloneNode(true) const itemContainer = fileUploadItem.querySelector(".upload_file_item") @@ -783,16 +816,38 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const percentText = fileUploadItem.querySelector(".file_upload_percent") const bar = fileUploadItem.querySelector(".file_progress_bar") const cancel = fileUploadItem.querySelector(".file_cancel_upload") + let preCancel = false; - itemContainer.dataset.state = 'pending' + itemContainer.dataset.state = PENDING name.textContent = file.name size.textContent = formatBytes(file.size) percentText.textContent = "0%" - + uploadList.append(fileUploadItem) + function preCancelUpload() { + console.log('cancelled') + preCancel = true; + itemText.classList.add(CANCELLED); + bar.classList.add(CANCELLED); + itemContainer.dataset.state = CANCELLED; + itemContainer.style.background = 'var(--upload_modal_file_upload_complete_background)'; + cancel.disabled = true; + cancel.removeEventListener("click", preCancelUpload); + uploadCancelButton.removeEventListener("click", preCancelUpload); + updateUploadTextAndList(); + } + + uploadCancelButton.addEventListener("click", preCancelUpload) + cancel.addEventListener("click", preCancelUpload) + return async () => { - return new Promise((resolve, reject) => { + if (preCancel) { + return Promise.resolve() + } + + return new Promise(async (resolve, reject) => { + const fileHash = await get256FileHash(file); const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('file', file); @@ -865,12 +920,14 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi if (CANCEL_UPLOAD) { cancelUpload() } else { - itemContainer.dataset.state = 'uploading' + itemContainer.dataset.state = UPLOADING xhr.addEventListener('readystatechange', onReadyStateChange); xhr.addEventListener("error", onError); xhr.addEventListener("abort", onAbort); xhr.upload.addEventListener('progress', onProgress); xhr.open('post', form.getAttribute("action"), true); + xhr.setRequestHeader('X-File-Hash', fileHash); + xhr.setRequestHeader('X-File-Hash-Function', 'SHA256'); xhr.send(formData); } }) -- cgit v1.2.3 From 577044ddbd70f5f128512c1a021329fb4c7e7eb3 Mon Sep 17 00:00:00 2001 From: Alec Di Vito Date: Sat, 22 Feb 2025 13:44:16 -0500 Subject: feat: address comments; add in new argument (`temp-directory`); add comments to upload code; add tests --- src/renderer.rs | 76 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 24 deletions(-) (limited to 'src/renderer.rs') diff --git a/src/renderer.rs b/src/renderer.rs index 8a87228..fae3751 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -609,7 +609,13 @@ fn chevron_down() -> Markup { } /// Partial: page header -fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favicon_route: &str, css_route: &str) -> Markup { +fn page_header( + title: &str, + file_upload: bool, + web_file_concurrency: usize, + favicon_route: &str, + css_route: &str, +) -> Markup { html! { head { meta charset="utf-8"; @@ -658,7 +664,9 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const UPLOADING = 'uploading', PENDING = 'pending', COMPLETE = 'complete', CANCELLED = 'cancelled', FAILED = 'failed' const UPLOAD_ITEM_ORDER = { UPLOADING: 0, PENDING: 1, COMPLETE: 2, CANCELLED: 3, FAILED: 4 } let CANCEL_UPLOAD = false; - // File Upload + + // File Upload dom elements. Used for interacting with the + // upload container. const form = document.querySelector('#file_submit'); const uploadArea = document.querySelector('#upload_area'); const uploadTitle = document.querySelector('#upload_title'); @@ -700,6 +708,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi dragForm.style.display = 'none'; }; + // Event listener for toggling the upload widget display on mobile. uploadWidgetToggle.addEventListener('click', function (e) { e.preventDefault(); if (uploadArea.style.height === "100vh") { @@ -713,6 +722,7 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi } }) + // Cancel all active and pending uploads uploadCancelButton.addEventListener('click', function (e) { e.preventDefault(); CANCEL_UPLOAD = true; @@ -723,7 +733,11 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadFiles() }) + // When uploads start, finish or are cancelled, the UI needs to reactively shows those + // updates of the state. This function updates the text on the upload widget to accurately + // show the state of all uploads. function updateUploadTextAndList() { + // All state is kept as `data-*` attributed on the HTML node. const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; const total = document.querySelectorAll("[data-state]").length; const uploads = queryLength(UPLOADING); @@ -733,13 +747,13 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi const failed = queryLength(FAILED); const allCompleted = completed + cancelled + failed; - // Update header + // Update header text based on remaining uploads let headerText = `${total - allCompleted} uploads remaining...`; if (total === allCompleted) { headerText = `Complete! Reloading Page!` } - // Update sub header + // Build a summary of statuses for sub header const statuses = [] if (uploads > 0) { statuses.push(`Uploading ${uploads}`) } if (pending > 0) { statuses.push(`Pending ${pending}`) } @@ -749,29 +763,37 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadTitle.textContent = headerText uploadActionText.textContent = statuses.join(', ') - - const items = Array.from(uploadList.querySelectorAll('li')); - items.sort((a, b) => UPLOAD_ITEM_ORDER[a.dataset.state] - UPLOAD_ITEM_ORDER[b.dataset.state]); - items.forEach((item, index) => { - if (uploadList.children[index] !== item) { - uploadList.insertBefore(item, uploadList.children[index]); - } - }); - } - - async function doWork(iterator, i) { - for (let [index, item] of iterator) { - await item(); - updateUploadTextAndList(); - } } + // Initiates the file upload process by disabling the ability for more files to be + // uploaded and creating async callbacks for each file that needs to be uploaded. + // Given the concurrency set by the server input arguments, it will try to process + // that many uploads at once function uploadFiles() { fileInput.disabled = true; + + // Map all the files into async callbacks (uploadFile is a function that returns a function) const callbacks = Array.from(fileInput.files).map(uploadFile); - const iterator = callbacks.entries(); + + // Get a list of all the callbacks const concurrency = CONCURRENCY === 0 ? callbacks.length : CONCURRENCY; - const workers = Array(concurrency).fill(iterator).map(doWork) + + // Worker function that continuously pulls tasks from the shared queue. + async function worker() { + while (callbacks.length > 0) { + // Remove a task from the front of the queue. + const task = callbacks.shift(); + if (task) { + await task(); + updateUploadTextAndList(); + } + } + } + + // Create a work stealing shared queue, split up between `concurrency` amount of workers. + const workers = Array.from({ length: concurrency }).map(worker); + + // Wait for all the workers to complete Promise.allSettled(workers) .finally(() => { updateUploadTextAndList(); @@ -797,7 +819,6 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi document.querySelector('input[type="file"]').addEventListener('change', async (e) => { const file = e.target.files[0]; const hash = await hashFile(file); - console.log('File hash:', hash); }); async function get256FileHash(file) { @@ -807,6 +828,10 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } + // Upload a file. This function will create a upload item in the upload + // widget from an HTML template. It then returns a promise which will + // be used to upload the file to the server and control the styles and + // interactions on the HTML list item. function uploadFile(file) { const fileUploadItem = fileUploadItemTemplate.content.cloneNode(true) const itemContainer = fileUploadItem.querySelector(".upload_file_item") @@ -825,8 +850,8 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadList.append(fileUploadItem) + // Cancel an upload before it even started. function preCancelUpload() { - console.log('cancelled') preCancel = true; itemText.classList.add(CANCELLED); bar.classList.add(CANCELLED); @@ -841,11 +866,14 @@ fn page_header(title: &str, file_upload: bool, web_file_concurrency: usize, favi uploadCancelButton.addEventListener("click", preCancelUpload) cancel.addEventListener("click", preCancelUpload) - return async () => { + // A callback function is return so that the upload doesn't start until + // we want it to. This is so that we have control over our desired concurrency. + return () => { if (preCancel) { return Promise.resolve() } + // Upload the single file in a multipart request. return new Promise(async (resolve, reject) => { const fileHash = await get256FileHash(file); const xhr = new XMLHttpRequest(); -- cgit v1.2.3