aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAlec Di Vito <divito.alec@gmail.com>2024-06-06 22:42:20 +0000
committerAlec Di Vito <divito.alec@gmail.com>2024-06-06 22:42:20 +0000
commitbe81eaefa526fa80e04166e86978e3a95263b4e3 (patch)
tree62b7a39877832859925cb3c88a0d5e0b17ea5054
parentBump deps (diff)
downloadminiserve-be81eaefa526fa80e04166e86978e3a95263b4e3.tar.gz
miniserve-be81eaefa526fa80e04166e86978e3a95263b4e3.zip
feat: Added HTML and Javascript progress bar when uploading files
-rw-r--r--data/style.scss156
-rw-r--r--data/themes/archlinux.scss8
-rw-r--r--data/themes/monokai.scss8
-rw-r--r--data/themes/squirrel.scss8
-rw-r--r--data/themes/zenburn.scss8
-rw-r--r--src/args.rs8
-rw-r--r--src/config.rs4
-rw-r--r--src/errors.rs1
-rw-r--r--src/file_op.rs18
-rw-r--r--src/renderer.rs243
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
{