diff options
author | Alec Di Vito <divito.alec@gmail.com> | 2024-06-06 22:42:20 +0000 |
---|---|---|
committer | Alec Di Vito <divito.alec@gmail.com> | 2024-06-06 22:42:20 +0000 |
commit | be81eaefa526fa80e04166e86978e3a95263b4e3 (patch) | |
tree | 62b7a39877832859925cb3c88a0d5e0b17ea5054 | |
parent | Bump deps (diff) | |
download | miniserve-be81eaefa526fa80e04166e86978e3a95263b4e3.tar.gz miniserve-be81eaefa526fa80e04166e86978e3a95263b4e3.zip |
feat: Added HTML and Javascript progress bar when uploading files
-rw-r--r-- | data/style.scss | 156 | ||||
-rw-r--r-- | data/themes/archlinux.scss | 8 | ||||
-rw-r--r-- | data/themes/monokai.scss | 8 | ||||
-rw-r--r-- | data/themes/squirrel.scss | 8 | ||||
-rw-r--r-- | data/themes/zenburn.scss | 8 | ||||
-rw-r--r-- | src/args.rs | 8 | ||||
-rw-r--r-- | src/config.rs | 4 | ||||
-rw-r--r-- | src/errors.rs | 1 | ||||
-rw-r--r-- | src/file_op.rs | 18 | ||||
-rw-r--r-- | src/renderer.rs | 243 |
10 files changed, 449 insertions, 13 deletions
diff --git a/data/style.scss b/data/style.scss index 32d2989..40b53a4 100644 --- a/data/style.scss +++ b/data/style.scss @@ -27,6 +27,143 @@ body { padding: 1.5rem 5rem; } +$upload_container_height: 18rem; + +.upload_area { + display: block; + position: fixed; + bottom: 1rem; + right: -105%; + color: #ffffff; + box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); + background: linear-gradient(135deg, #73a5ff, #5477f5); + padding: 0px; + margin: 0px; + opacity: 1; // Change this + transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); + border-radius: 4px; + text-decoration: none; + min-width: 400px; + max-width: 600px; + z-index: 2147483647; + max-height: $upload_container_height; + overflow: hidden; + + &.active { + right: 1rem; + } +} + +.upload_container { + max-height: $upload_container_height; + display: flex; + flex-direction: column; +} + +.upload_header { + padding: 1rem; + background-color: var(--upload_modal_header_background); + color: var(--upload_modal_header_color); +} + +.upload_action { + background-color: var(--upload_modal_sub_header_background); + color: var(--upload_modal_header_color); + padding: 0.25rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75em; + font-weight: 500; +} + +.upload_cancel { + background: none; + border: none; + font-weight: 500; + cursor: pointer; +} + +.upload_files { + padding: 0px; + margin: 0px; + flex: 1; + overflow-y: auto; + max-height:inherit; +} + +.upload_file_list { + background-color: var(--upload_modal_file_item_background); + color: var(--upload_modal_file_item_color); + padding: 0px; + margin: 0px; + list-style: none; + list-style: none; + display: flex; + flex-direction: column; + align-items: stretch; + overflow-y: scroll; +} + +.upload_file_container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem calc(1rem - 2px) 1rem; +} + +.upload_file_action { + display: flex; + justify-content: right; +} + +.file_progress_bar { + width: 0%; + border-top: 2px solid var(--progress_bar_background); + transition: width 0.25s ease; + + &.cancelled { + border-color: var(--error_color); + } + + &.failed { + border-color: var(--error_color); + } + + &.complete { + border-color: var(--success_color); + } +} + +.upload_file_text { + font-size: 0.80em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.cancelled { + text-decoration: line-through; + } + + &.failed { + text-decoration: line-through; + } +} + +.file_cancel_upload { + padding-left: 0.25rem; + font-size: 1em; + cursor: pointer; + border: none; + background: inherit; + font-size: 1em; + color: var(--error_color); + + &.complete { + color: var(--success_color); + } +} + .title { word-break: break-all; } @@ -524,6 +661,25 @@ th span.active span { .back { right: 1.5rem; } + + $upload_container_height_mobile: 60vh; + + .upload_area { + width: 100vw; + max-height: $upload_container_height_mobile; + max-width: unset; + min-width: unset; + bottom: 0; + + &.active { + right: 0; + left: 0; + } + } + + .upload_container { + max-height: $upload_container_height_mobile; + } } @media (max-width: 600px) { diff --git a/data/themes/archlinux.scss b/data/themes/archlinux.scss index f95b8bb..16020a0 100644 --- a/data/themes/archlinux.scss +++ b/data/themes/archlinux.scss @@ -45,6 +45,14 @@ $generate_default: true !default; --size_text_color: #fefefe; --error_color: #e44b4b; --footer_color: #8eabcc; + --success_color: #52e28a; + --upload_modal_header_background: #5294e2; + --upload_modal_header_color: #eeeeee; + --upload_modal_sub_header_background: #35547a; + --upload_modal_file_item_background: #eeeeee; + --upload_modal_file_item_color: #111111; + --upload_modal_file_upload_complete_background: #cccccc; + --progress_bar_background: #5294e2; }; @if $generate_default { diff --git a/data/themes/monokai.scss b/data/themes/monokai.scss index 4a47794..60c45e7 100644 --- a/data/themes/monokai.scss +++ b/data/themes/monokai.scss @@ -45,6 +45,14 @@ $generate_default: true !default; --size_text_color: #f8f8f2; --error_color: #d02929; --footer_color: #56c9df; + --success_color: #52e28a; + --upload_modal_header_background: #75715e; + --upload_modal_header_color: #eeeeee; + --upload_modal_sub_header_background: #323129; + --upload_modal_file_item_background: #eeeeee; + --upload_modal_file_item_color: #111111; + --upload_modal_file_upload_complete_background: #cccccc; + --progress_bar_background: #5294e2; }; @if $generate_default { diff --git a/data/themes/squirrel.scss b/data/themes/squirrel.scss index 9eb572e..7ec47e6 100644 --- a/data/themes/squirrel.scss +++ b/data/themes/squirrel.scss @@ -45,6 +45,14 @@ $generate_default: true !default; --size_text_color: #ffffff; --error_color: #d02424; --footer_color: #898989; + --success_color: #52e28a; + --upload_modal_header_background: #323232; + --upload_modal_header_color: #eeeeee; + --upload_modal_sub_header_background: #171616; + --upload_modal_file_item_background: #eeeeee; + --upload_modal_file_item_color: #111111; + --upload_modal_file_upload_complete_background: #cccccc; + --progress_bar_background: #5294e2; }; @if $generate_default { diff --git a/data/themes/zenburn.scss b/data/themes/zenburn.scss index 9eb4d11..efb2e83 100644 --- a/data/themes/zenburn.scss +++ b/data/themes/zenburn.scss @@ -45,6 +45,14 @@ $generate_default: true !default; --size_text_color: #efefef; --error_color: #d06565; --footer_color: #bfaf9f; + --success_color: #52e28a; + --upload_modal_header_background: #7f9f7f; + --upload_modal_header_color: #eeeeee; + --upload_modal_sub_header_background: #404e40; + --upload_modal_file_item_background: #eeeeee; + --upload_modal_file_item_color: #111111; + --upload_modal_file_upload_complete_background: #cccccc; + --progress_bar_background: #5294e2; }; @if $generate_default { diff --git a/src/args.rs b/src/args.rs index 95c8bff..a58504b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -165,6 +165,14 @@ pub struct CliArgs { /// When specified via environment variable, a path always neesd to the specified. #[arg(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_UPLOAD_DIR")] pub allowed_upload_dir: Option<Vec<PathBuf>>, + + /// Configure amount of concurrent uploads when visiting the website. Must have + /// upload-files option enabled for this setting to matter. + /// + /// For example, a value of 4 would mean that the web browser will only upload + /// 4 files at a time to the web server when using the web browser interface. + #[arg(long = "web-upload-files-concurrency", env = "MINISERVE_WEB_UPLOAD_CONCURRENCY", default_value = "0")] + pub web_upload_concurrency: usize, /// Enable creating directories #[arg( diff --git a/src/config.rs b/src/config.rs index 3643502..6e8b89e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -101,6 +101,9 @@ pub struct MiniserveConfig { /// Enable file upload pub file_upload: bool, + /// Max amount of concurrency when uploading multiple files + pub web_upload_concurrency: usize, + /// List of allowed upload directories pub allowed_upload_dir: Vec<String>, @@ -301,6 +304,7 @@ impl MiniserveConfig { show_qrcode: args.qrcode, mkdir_enabled: args.mkdir_enabled, file_upload: args.allowed_upload_dir.is_some(), + web_upload_concurrency: args.web_upload_concurrency, allowed_upload_dir, uploadable_media_type, tar_enabled: args.enable_tar, diff --git a/src/errors.rs b/src/errors.rs index 21f8f12..600834b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -129,6 +129,7 @@ where let res = fut.await?.map_into_boxed_body(); if (res.status().is_client_error() || res.status().is_server_error()) + && res.request().path() != "/upload" && res .headers() .get(header::CONTENT_TYPE) diff --git a/src/file_op.rs b/src/file_op.rs index e22e3e9..9f5902c 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -4,7 +4,7 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use actix_web::{http::header, web, HttpRequest, HttpResponse}; -use futures::TryFutureExt; +use futures::{StreamExt, TryFutureExt}; use futures::TryStreamExt; use serde::Deserialize; use tokio::fs::File; @@ -20,7 +20,7 @@ use crate::{ /// /// Returns total bytes written to file. async fn save_file( - field: actix_multipart::Field, + field: &mut actix_multipart::Field, file_path: PathBuf, overwrite_files: bool, ) -> Result<u64, RuntimeError> { @@ -33,8 +33,8 @@ async fn save_file( RuntimeError::InsufficientPermissionsError(file_path.display().to_string()), ), Err(err) => Err(RuntimeError::IoError( - format!("Failed to create {}", file_path.display()), - err, + format!("Failed to create {}", file_path.display()), + err, )), Ok(v) => Ok(v), }?; @@ -164,7 +164,15 @@ async fn handle_multipart( } } - save_file(field, path.join(filename_path), overwrite_files).await + match save_file(&mut field, path.join(filename_path), overwrite_files).await { + Ok(bytes) => Ok(bytes), + Err(err) => { + // Required for file upload. If entire stream is not consumed, javascript + // XML HTTP Request will never complete. + while field.next().await.is_some() {} + Err(err) + }, + } } /// Query parameters used by upload and rm APIs 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#" - <script> + script { + (format!("const CONCURRENCY = {web_file_concurrency};")) + (PreEscaped(r#" window.onload = function() { + // Constants + 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 + const form = document.querySelector('#file_submit'); + const uploadArea = document.querySelector('#upload_area'); + const uploadTitle = document.querySelector('#upload_title'); + const uploadActionText = document.querySelector('#upload_action_text'); + const uploadCancelButton = document.querySelector('#upload_cancel'); + const uploadList = document.querySelector('#upload_file_list'); + const fileUploadItemTemplate = document.querySelector('#upload_file_item'); + const dropContainer = document.querySelector('#drop-container'); const dragForm = document.querySelector('.drag-form'); const fileInput = document.querySelector('#file-input'); @@ -643,12 +688,194 @@ fn page_header(title: &str, file_upload: bool, favicon_route: &str, css_route: & dropContainer.ondrop = function(e) { e.preventDefault(); fileInput.files = e.dataTransfer.files; - file_submit.submit(); + form.requestSubmit(); dragForm.style.display = 'none'; }; + + uploadCancelButton.addEventListener('click', function (e) { + e.preventDefault(); + CANCEL_UPLOAD = true; + }) + + form.addEventListener('submit', function (e) { + e.preventDefault() + uploadFiles() + }) + + const queryLength = (state) => document.querySelectorAll(`[data-state='${state}']`).length; + function updateUploadText() { + const total = document.querySelectorAll("[data-state]").length; + const uploads = queryLength(UPLOADING); + const pending = queryLength(PENDING); + const completed = queryLength(COMPLETE); + const cancelled = queryLength(CANCELLED); + const failed = queryLength(FAILED); + const allCompleted = completed + cancelled + failed; + + // Update header + let headerText = `${total - allCompleted} uploads remaining...`; + if (total === allCompleted) { + headerText = `Complete! Reloading Page!` + } + + // Update sub header + const statuses = [] + if (uploads > 0) { statuses.push(`Uploading ${uploads}`) } + if (pending > 0) { statuses.push(`Pending ${pending}`) } + if (completed > 0) { statuses.push(`Complete ${completed}`) } + if (cancelled > 0) { statuses.push(`Cancelled ${cancelled}`) } + if (failed > 0) { statuses.push(`Failed ${failed}`) } + + 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)) + } + + async function doWork(iterator, i) { + for (let [index, item] of iterator) { + await item(); + updateUploadText(); + } + } + + function uploadFiles() { + fileInput.disabled = true; + const callbacks = Array.from(fileInput.files).map(uploadFile); + 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')) + .finally(() => { + updateUploadText(); + form.reset(); + setTimeout(() => { uploadArea.classList.remove('active'); }, 1000) + setTimeout(() => { window.location.reload(); }, 1500) + }) + + updateUploadText(); + uploadArea.classList.add('active') + uploadList.scrollTo(0, 0) + } + + function formatBytes(bytes, decimals) { + if (bytes == 0) return '0 Bytes'; + var k = 1024, + dm = decimals || 2, + sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], + i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + function uploadFile(file) { + const fileUploadItem = fileUploadItemTemplate.content.cloneNode(true) + const itemContainer = fileUploadItem.querySelector(".upload_file_item") + const itemText = fileUploadItem.querySelector(".upload_file_text") + const size = fileUploadItem.querySelector(".file_size") + const name = fileUploadItem.querySelector(".file_name") + const percentText = fileUploadItem.querySelector(".file_upload_percent") + const bar = fileUploadItem.querySelector(".file_progress_bar") + const cancel = fileUploadItem.querySelector(".file_cancel_upload") + + itemContainer.dataset.state = 'pending' + name.textContent = file.name + size.textContent = formatBytes(file.size) + percentText.textContent = "0%" + + uploadList.append(fileUploadItem) + + return async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', file); + + function onReadyStateChange(e) { + console.log('readystatechange', e) + if (e.target.readyState == 4) { + if (e.target.status == 200) { + completeSuccess() + } else { + failedUpload(e.target.status) + } + } + } + + function onError(e) { + failedUpload() + } + + function onAbort(e) { + cancelUpload() + } + + function onProgress (e) { + update(Math.round((e.loaded / e.total) * 100)); + } + + function update(uploadPercent) { + let wholeNumber = Math.floor(uploadPercent) + percentText.textContent = `${wholeNumber}%` + bar.style.width = `${wholeNumber}%` + } + + function completeSuccess() { + cancel.textContent = '✔'; + cancel.classList.add(COMPLETE); + bar.classList.add(COMPLETE); + cleanUp(COMPLETE) + } + + function failedUpload(statusCode) { + cancel.textContent = `${statusCode} ⚠`; + itemText.classList.add(FAILED); + bar.classList.add(FAILED); + cleanUp(FAILED); + } + + function cancelUpload() { + xhr.abort() + itemText.classList.add(CANCELLED); + bar.classList.add(CANCELLED); + cleanUp(CANCELLED); + } + + function cleanUp(state) { + itemContainer.dataset.state = state; + itemContainer.style.background = 'var(--upload_modal_file_upload_complete_background)'; + cancel.disabled = true; + cancel.removeEventListener("click", cancelUpload) + uploadCancelButton.removeEventListener("click", cancelUpload) + xhr.removeEventListener('readystatechange', onReadyStateChange); + xhr.removeEventListener("error", onError); + xhr.removeEventListener("abort", onAbort); + xhr.upload.removeEventListener('progress', onProgress); + resolve() + } + + uploadCancelButton.addEventListener("click", cancelUpload) + cancel.addEventListener("click", cancelUpload) + + if (CANCEL_UPLOAD) { + cancelUpload() + } else { + 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.send(formData); + } + }) + } + } } - </script> - "#)) + "#)) + } } } } @@ -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 { |