+
+ {State.Finished
+ ? `Uploaded ${Files}.`
+ : `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)})`}
+
+ {!State.Finished && (
+
+ )}
+
+ );
+}
diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css
index bba6ebf..48f2bf7 100644
--- a/Frontend/src/Styles/Theme.css
+++ b/Frontend/src/Styles/Theme.css
@@ -1135,6 +1135,38 @@ img,
z-index: 200;
}
+.UploadToast {
+ position: fixed;
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px));
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 260px;
+ max-width: 480px;
+ padding: 10px 14px;
+ background: var(--Surface);
+ border: 1px solid var(--Purple);
+ border-radius: 6px;
+ color: var(--Text);
+ font-size: 13px;
+ z-index: 200;
+}
+
+.UploadToastBar {
+ height: 4px;
+ background: var(--SurfaceAlt);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.UploadToastFill {
+ height: 100%;
+ background: var(--Purple);
+ transition: width 0.2s ease;
+}
+
.ErrorToastClose {
background: none;
border: none;
diff --git a/Frontend/src/Upload.js b/Frontend/src/Upload.js
new file mode 100644
index 0000000..aab7643
--- /dev/null
+++ b/Frontend/src/Upload.js
@@ -0,0 +1,95 @@
+// Uploading files, with progress: the sidebar and the directory pane both
+// upload through UploadAll, and UploadToast.jsx shows how it's going
+// ("Uploading 3 files: 45% (12 MB of 27 MB)", then "Uploaded 3 files.").
+// Small files go up together, large ones in chunks (Api.js).
+
+import { IsLargeUpload, UploadFileChunked, UploadFiles } from "./Api.js";
+
+let State = null; // { Count, Done, Total (bytes), Finished }
+let ActiveUploads = 0;
+const Listeners = new Set();
+
+function Publish(Next) {
+ State = Next;
+ Listeners.forEach((Listener) => Listener());
+}
+
+export function SubscribeUploads(Listener) {
+ Listeners.add(Listener);
+ return () => Listeners.delete(Listener);
+}
+
+export function GetUploadState() {
+ return State;
+}
+
+export function FormatSize(Bytes) {
+ const Units = ["B", "KB", "MB", "GB", "TB"];
+ let Size = Bytes;
+ let Unit = 0;
+ while (Size >= 1024 && Unit < Units.length - 1) {
+ Size /= 1024;
+ Unit++;
+ }
+ return `${Unit === 0 ? Size : Size.toFixed(1)} ${Units[Unit]}`;
+}
+
+// Closing the tab mid-upload would lose it, so ask first.
+window.addEventListener("beforeunload", (Event) => {
+ if (ActiveUploads > 0) {
+ Event.preventDefault();
+ Event.returnValue = "";
+ }
+});
+
+// Uploads Files into DirectoryPath; returns the errors (empty if all went up).
+export async function UploadAll(RootName, DirectoryPath, Files) {
+ const FileArray = Array.from(Files || []);
+ if (FileArray.length === 0) return [];
+
+ const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File));
+ const LargeFiles = FileArray.filter((File) => IsLargeUpload(File));
+ const Total = FileArray.reduce((Sum, File) => Sum + File.size, 0);
+ const Errors = [];
+ let Finished = 0; // bytes of the parts already done
+
+ const Report = (Current) =>
+ Publish({ Count: FileArray.length, Done: Math.min(Total, Finished + Current), Total, Finished: false });
+
+ ActiveUploads++;
+ Report(0);
+ try {
+ if (SmallFiles.length > 0) {
+ try {
+ const Result = await UploadFiles(RootName, DirectoryPath, SmallFiles, Report);
+ if (Result.Errors && Result.Errors.length > 0) {
+ Errors.push(...Result.Errors);
+ }
+ } catch (Error) {
+ Errors.push(Error.message);
+ }
+ Finished += SmallFiles.reduce((Sum, File) => Sum + File.size, 0);
+ }
+
+ for (const File of LargeFiles) {
+ try {
+ await UploadFileChunked(RootName, DirectoryPath, File, Report);
+ } catch (Error) {
+ Errors.push(`${File.name}: ${Error.message}`);
+ }
+ Finished += File.size;
+ }
+ } finally {
+ ActiveUploads--;
+ }
+
+ // Errors show in the error toast instead; a success stays up for a moment.
+ const Done = { Count: FileArray.length, Done: Total, Total, Finished: true };
+ Publish(Errors.length > 0 ? null : Done);
+ if (Errors.length === 0) {
+ setTimeout(() => {
+ if (State === Done) Publish(null);
+ }, 3000);
+ }
+ return Errors;
+}