From 596d7ac35da94bd1a294f85e5dc8ca47fa091f8a Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Sun, 13 Jul 2025 12:35:48 +0200 Subject: [PATCH] improved file uploads --- backend/src/api/warrens/upload_files.rs | 2 +- backend/src/error.rs | 6 + frontend/components/actions/UploadDialog.vue | 241 ++++++++++++------ .../components/actions/UploadListEntry.vue | 56 ++++ frontend/components/ui/progress/Progress.vue | 38 +++ frontend/components/ui/progress/index.ts | 1 + frontend/layouts/default.vue | 18 +- frontend/lib/api/warrens.ts | 36 ++- frontend/stores/index.ts | 10 +- frontend/stores/upload.ts | 21 ++ frontend/types/index.ts | 11 + frontend/utils/api.ts | 17 ++ 12 files changed, 370 insertions(+), 87 deletions(-) create mode 100644 frontend/components/actions/UploadListEntry.vue create mode 100644 frontend/components/ui/progress/Progress.vue create mode 100644 frontend/components/ui/progress/index.ts create mode 100644 frontend/stores/upload.ts diff --git a/backend/src/api/warrens/upload_files.rs b/backend/src/api/warrens/upload_files.rs index 98d7939..1b70aa2 100644 --- a/backend/src/api/warrens/upload_files.rs +++ b/backend/src/api/warrens/upload_files.rs @@ -23,7 +23,7 @@ pub(super) async fn route( }; let file_name = field.file_name().map(str::to_owned).unwrap(); - let data = field.bytes().await.unwrap(); + let data = field.bytes().await?; warren .upload(state.serve_dir(), rest.as_deref(), &file_name, &data) diff --git a/backend/src/error.rs b/backend/src/error.rs index 58bdc8c..0d8d2c6 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -1,5 +1,8 @@ +use std::error::Error; + use axum::{ body::Body, + extract::multipart::MultipartError, http::{StatusCode, header::InvalidHeaderValue}, response::{IntoResponse, Response}, }; @@ -19,6 +22,8 @@ pub enum AppError { Var(#[from] std::env::VarError), #[error("InvalidHeaderValue: {0}")] InvalidHeaderValue(#[from] InvalidHeaderValue), + #[error("Multipart: {0}")] + Multipart(#[from] MultipartError), } impl IntoResponse for AppError { @@ -34,6 +39,7 @@ impl IntoResponse for AppError { Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Multipart(_) => StatusCode::INTERNAL_SERVER_ERROR, }; Response::builder() diff --git a/frontend/components/actions/UploadDialog.vue b/frontend/components/actions/UploadDialog.vue index 477df7d..f917935 100644 --- a/frontend/components/actions/UploadDialog.vue +++ b/frontend/components/actions/UploadDialog.vue @@ -10,8 +10,14 @@ import { } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; import byteSize from 'byte-size'; import { uploadToWarren } from '~/lib/api/warrens'; +import { toast } from 'vue-sonner'; +import UploadListEntry from './UploadListEntry.vue'; + +const uploadStore = useUploadStore(); + const warrenRoute = useWarrenRoute(); const fileInputElement = ref( @@ -19,11 +25,16 @@ const fileInputElement = ref( ); const uploading = ref(false); -const open = ref(false); -const files = ref>([]); const presentPaths = new Set(); function onFilesChanged(event: Event) { + if (warrenRoute.value == null) { + toast.warning('Upload', { + description: 'Enter a warren before attempting to upload files', + }); + return; + } + if (event.target == null) { return; } @@ -34,64 +45,144 @@ function onFilesChanged(event: Event) { return; } + if (uploadStore.path == null) { + uploadStore.path = warrenRoute.value; + } else if (uploadStore.path !== warrenRoute.value) { + toast.warning('Upload', { + description: + 'The unfinished items belong to a different directory. Remove them before attempting to upload to a different directory.', + }); + return; + } + for (const file of target.files) { if (presentPaths.has(file.name)) { continue; } - files.value.push({ uploaded: false, data: file }); + uploadStore.files.push({ + status: 'not_uploaded', + data: file, + }); presentPaths.add(file.name); } fileInputElement.value.value = ''; } -async function submit() { - if (files.value.length < 1) { - return; - } - - uploading.value = true; - - const dt = new DataTransfer(); - for (const file of files.value) { - if (!file.uploaded) { - dt.items.add(file.data); - } - } - - const { success } = await uploadToWarren(warrenRoute.value, dt.files); - - uploading.value = false; - if (success) { - for (const file of files.value) { - file.uploaded = true; - } - } -} - function removeFile(index: number) { - if (files.value.length <= index) { + if (uploadStore.files.length <= index) { return; } - const [file] = files.value.splice(index, 1); + const [file] = uploadStore.files.splice(index, 1); presentPaths.delete(file.data.name); + + if (uploadStore.files.length < 1) { + uploadStore.path = null; + uploadStore.progress = null; + } } function clearCompletedFiles() { - files.value = files.value.filter((f) => { - if (f.uploaded) { + uploadStore.files = uploadStore.files.filter((f) => { + if (f.status === 'completed') { presentPaths.delete(f.data.name); return false; } return true; }); + + if (uploadStore.files.length < 1) { + uploadStore.path = null; + uploadStore.progress = null; + } +} + +function updateFileListStatus(loadedBytes: number, ended: boolean = false) { + let acc = 0; + + for (let i = uploadStore.startIndex; i < uploadStore.files.length; i++) { + if (uploadStore.progress == null) { + uploadStore.files[i].status = 'not_uploaded'; + continue; + } + + acc += uploadStore.files[i].data.size; + + if (acc > loadedBytes) { + if (ended) { + for (let j = i; j < uploadStore.files.length; j++) { + uploadStore.files[j].status = 'failed'; + } + } else { + uploadStore.files[i].status = 'uploading'; + } + break; + } + + uploadStore.files[i].status = 'completed'; + } +} + +async function submit() { + if (uploadStore.path == null) { + return; + } + + const dt = new DataTransfer(); + + uploadStore.startIndex = uploadStore.files.findIndex( + (f) => f.status !== 'completed' + ); + let calculatedTotal = 0; + + for (let i = uploadStore.startIndex; i < uploadStore.files.length; i++) { + const file = uploadStore.files[i]; + + if (file.status !== 'completed') { + dt.items.add(file.data); + calculatedTotal += file.data.size; + } + } + + uploading.value = true; + + uploadStore.progress = { + loadedBytes: 0, + totalBytes: calculatedTotal, + }; + + const { success } = await uploadToWarren( + uploadStore.path, + dt.files, + (loaded, total) => { + if (uploadStore.progress != null) { + uploadStore.progress.loadedBytes = loaded; + uploadStore.progress.totalBytes = total; + + updateFileListStatus(loaded, false); + } + } + ); + + uploading.value = false; + if (success) { + if (uploadStore.progress != null) { + uploadStore.progress.loadedBytes = uploadStore.progress.totalBytes; + } + + for (const file of uploadStore.files) { + file.status = 'completed'; + } + } else { + updateFileListStatus(uploadStore.progress.loadedBytes, true); + } }