mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 02:38:11 +02:00
1 parent
db81507b7c
commit
843ad1986e
9 files changed
+259
-78
No files matched your search
+34
-4
@@ -84,6 +84,11 @@ class Browser(ft.Column):
|
|||||||
spacing=8,
|
spacing=8,
|
||||||
tight=True,
|
tight=True,
|
||||||
)
|
)
|
||||||
|
# Shown while uploading: which file, how far (Uploads' progress).
|
||||||
|
self.UploadText = ft.Text("", size=12, color=Palette.Overlay0)
|
||||||
|
self.UploadBar = ft.ProgressBar(value=0, color=Palette.Mauve, bgcolor=Palette.Surface0, bar_height=4)
|
||||||
|
self.UploadRow = ft.Container(ft.Column([self.UploadText, self.UploadBar], spacing=4),
|
||||||
|
padding=ft.Padding.symmetric(horizontal=8, vertical=6), visible=False)
|
||||||
self.EntryList = ft.ListView(expand=True, spacing=2)
|
self.EntryList = ft.ListView(expand=True, spacing=2)
|
||||||
# One shared popup host, opened programmatically (see _RequestMenu).
|
# One shared popup host, opened programmatically (see _RequestMenu).
|
||||||
# Its built-in triggers can't be switched off, so they're set to
|
# Its built-in triggers can't be switched off, so they're set to
|
||||||
@@ -103,6 +108,7 @@ class Browser(ft.Column):
|
|||||||
# and the breadcrumbs get the rest (and wrap when it's tight).
|
# and the breadcrumbs get the rest (and wrap when it's tight).
|
||||||
ft.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]),
|
ft.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]),
|
||||||
ft.Divider(height=1, color=Palette.Surface1),
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
self.UploadRow,
|
||||||
self.FileArea,
|
self.FileArea,
|
||||||
]
|
]
|
||||||
self._ShowList(Update=False)
|
self._ShowList(Update=False)
|
||||||
@@ -600,19 +606,43 @@ class Browser(ft.Column):
|
|||||||
return
|
return
|
||||||
Saved, Failed = 0, []
|
Saved, Failed = 0, []
|
||||||
Server = self.TheApp.Server
|
Server = self.TheApp.Server
|
||||||
for LocalFile in Paths:
|
Sizes = [LocalFile.stat().st_size if LocalFile.is_file() else 0 for LocalFile in Paths]
|
||||||
if Server:
|
Total = sum(Sizes)
|
||||||
self.TheApp.Notify(f"Uploading {LocalFile.name} ({HumanSize(LocalFile.stat().st_size)})...", "info")
|
Verb = "Uploading" if Server else "Copying in"
|
||||||
|
|
||||||
|
Shown = [-1, -1] # (file, percent) on screen: redraw only when it changes
|
||||||
|
|
||||||
|
def ShowProgress(Index: int, Fraction: float) -> None:
|
||||||
|
Done = sum(Sizes[:Index]) + Sizes[Index] * Fraction
|
||||||
|
Percent = int(Done / Total * 100) if Total else 100
|
||||||
|
if Shown == [Index, Percent]:
|
||||||
|
return
|
||||||
|
Shown[:] = [Index, Percent]
|
||||||
|
Of = f" ({Index + 1} of {len(Paths)})" if len(Paths) > 1 else ""
|
||||||
|
self.UploadText.value = (f"{Verb} {Paths[Index].name}{Of}: {Percent}% "
|
||||||
|
f"({HumanSize(int(Done))} of {HumanSize(Total)})")
|
||||||
|
self.UploadBar.value = Done / Total if Total else 1
|
||||||
|
self.UploadRow.update()
|
||||||
|
|
||||||
|
self.UploadRow.visible = True
|
||||||
|
for Index, LocalFile in enumerate(Paths):
|
||||||
|
ShowProgress(Index, 0)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile)
|
# Large files go up in chunks and report each one; small ones in one go.
|
||||||
|
await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile,
|
||||||
|
lambda Fraction, I=Index: ShowProgress(I, Fraction))
|
||||||
Saved += 1
|
Saved += 1
|
||||||
except SourceError as Error:
|
except SourceError as Error:
|
||||||
if Error.Status == 401:
|
if Error.Status == 401:
|
||||||
|
self.UploadRow.visible = False
|
||||||
|
self.UploadRow.update()
|
||||||
self.TheApp.HandleError(Error)
|
self.TheApp.HandleError(Error)
|
||||||
return
|
return
|
||||||
Failed.append(f"{LocalFile.name}: {Error}")
|
Failed.append(f"{LocalFile.name}: {Error}")
|
||||||
except OSError as Error:
|
except OSError as Error:
|
||||||
Failed.append(f"{LocalFile.name}: {Error}")
|
Failed.append(f"{LocalFile.name}: {Error}")
|
||||||
|
self.UploadRow.visible = False
|
||||||
|
self.UploadRow.update()
|
||||||
self.Refresh()
|
self.Refresh()
|
||||||
if Failed:
|
if Failed:
|
||||||
self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error")
|
self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error")
|
||||||
|
|||||||
+30
-4
@@ -5,6 +5,7 @@ entries shaped like the server's /api/tree items, so the rest of the client
|
|||||||
never needs to know which one it's talking to."""
|
never needs to know which one it's talking to."""
|
||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
import io
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -412,7 +413,8 @@ class ServerSource(Source):
|
|||||||
Params = self._At(Root, DirectoryPath)
|
Params = self._At(Root, DirectoryPath)
|
||||||
if Size <= ChunkedUploadThresholdBytes:
|
if Size <= ChunkedUploadThresholdBytes:
|
||||||
with LocalFile.open("rb") as Handle:
|
with LocalFile.open("rb") as Handle:
|
||||||
Result = self._Json("POST", "/upload", params=Params, files=[("Files", (LocalFile.name, Handle))])
|
Sending = _CountingReader(Handle, Size, OnProgress) if OnProgress else Handle
|
||||||
|
Result = self._Json("POST", "/upload", params=Params, files=[("Files", (LocalFile.name, Sending))])
|
||||||
if Result.get("Errors"):
|
if Result.get("Errors"):
|
||||||
raise SourceError("; ".join(Result["Errors"]))
|
raise SourceError("; ".join(Result["Errors"]))
|
||||||
return Result["Saved"][0]
|
return Result["Saved"][0]
|
||||||
@@ -423,10 +425,13 @@ class ServerSource(Source):
|
|||||||
for ChunkIndex in range(TotalChunks):
|
for ChunkIndex in range(TotalChunks):
|
||||||
ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex,
|
ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex,
|
||||||
"TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
"TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
||||||
self._Request("POST", "/upload/chunk", params=ChunkParams,
|
Chunk = Handle.read(UploadChunkSizeBytes)
|
||||||
files=[("Chunk", ("chunk", Handle.read(UploadChunkSizeBytes)))])
|
Sending = io.BytesIO(Chunk)
|
||||||
if OnProgress:
|
if OnProgress:
|
||||||
OnProgress((ChunkIndex + 1) / TotalChunks)
|
Before = ChunkIndex * UploadChunkSizeBytes
|
||||||
|
Sending = _CountingReader(Sending, len(Chunk),
|
||||||
|
lambda Fraction, B=Before, L=len(Chunk): OnProgress((B + L * Fraction) / Size))
|
||||||
|
self._Request("POST", "/upload/chunk", params=ChunkParams, files=[("Chunk", ("chunk", Sending))])
|
||||||
Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
||||||
return self._Json("POST", "/upload/complete", params=Done)["Saved"]
|
return self._Json("POST", "/upload/complete", params=Done)["Saved"]
|
||||||
|
|
||||||
@@ -474,5 +479,26 @@ class ServerSource(Source):
|
|||||||
return self._Request("POST", "/diagram", json=Body).content
|
return self._Request("POST", "/diagram", json=Body).content
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingReader:
|
||||||
|
"""A file handle that reports how much of it has been read (sent): httpx
|
||||||
|
reads an upload's file in pieces, so this is its progress."""
|
||||||
|
|
||||||
|
def __init__(self, Handle, Size: int, OnProgress: Callable[[float], None]):
|
||||||
|
self._Handle = Handle
|
||||||
|
self._Size = Size
|
||||||
|
self._OnProgress = OnProgress
|
||||||
|
self._Sent = 0
|
||||||
|
|
||||||
|
def read(self, Count: int = -1) -> bytes:
|
||||||
|
Data = self._Handle.read(Count)
|
||||||
|
self._Sent += len(Data)
|
||||||
|
if self._Size:
|
||||||
|
self._OnProgress(min(1.0, self._Sent / self._Size))
|
||||||
|
return Data
|
||||||
|
|
||||||
|
def __getattr__(self, Name: str):
|
||||||
|
return getattr(self._Handle, Name) # seek, tell, fileno: for the length
|
||||||
|
|
||||||
|
|
||||||
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
||||||
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
||||||
+36
-16
@@ -171,18 +171,41 @@ export function GetDownloadUrl(RootName, RelativePath) {
|
|||||||
return `${ApiBase}/download?${Params}`;
|
return `${ApiBase}/download?${Params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function UploadFiles(RootName, RelativePath, Files) {
|
// Uploads go through XMLHttpRequest: unlike fetch, it reports how much has
|
||||||
|
// been sent. OnProgress(BytesSent) is optional.
|
||||||
|
function PostFormWithProgress(Url, Form, OnProgress) {
|
||||||
|
return new Promise((Resolve, Reject) => {
|
||||||
|
const Request = new XMLHttpRequest();
|
||||||
|
Request.open("POST", Url);
|
||||||
|
Request.withCredentials = true;
|
||||||
|
if (OnProgress) {
|
||||||
|
Request.upload.onprogress = (Event) => OnProgress(Event.loaded);
|
||||||
|
}
|
||||||
|
Request.onload = () => {
|
||||||
|
let Body = null;
|
||||||
|
try {
|
||||||
|
Body = JSON.parse(Request.responseText);
|
||||||
|
} catch {
|
||||||
|
Body = null;
|
||||||
|
}
|
||||||
|
if (Request.status >= 200 && Request.status < 300) {
|
||||||
|
Resolve(Body);
|
||||||
|
} else {
|
||||||
|
Reject(new Error(Body && Body.detail ? Body.detail : Request.statusText || `Upload failed (${Request.status})`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Request.onerror = () => Reject(new Error("Upload failed: couldn't reach the server."));
|
||||||
|
Request.send(Form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadFiles(RootName, RelativePath, Files, OnProgress) {
|
||||||
const Form = new FormData();
|
const Form = new FormData();
|
||||||
for (const File of Files) {
|
for (const File of Files) {
|
||||||
Form.append("Files", File);
|
Form.append("Files", File);
|
||||||
}
|
}
|
||||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||||
const Response = await fetch(`${ApiBase}/upload?${Params}`, {
|
return PostFormWithProgress(`${ApiBase}/upload?${Params}`, Form, OnProgress);
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: Form,
|
|
||||||
});
|
|
||||||
return ParseJsonOrThrow(Response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadChunkSizeBytes = 20 * 1024 * 1024;
|
const UploadChunkSizeBytes = 20 * 1024 * 1024;
|
||||||
@@ -199,7 +222,7 @@ function GenerateUploadId() {
|
|||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, FileName, ChunkBlob) {
|
function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, FileName, ChunkBlob, OnProgress) {
|
||||||
const Form = new FormData();
|
const Form = new FormData();
|
||||||
Form.append("Chunk", ChunkBlob, "chunk");
|
Form.append("Chunk", ChunkBlob, "chunk");
|
||||||
const Params = new URLSearchParams({
|
const Params = new URLSearchParams({
|
||||||
@@ -210,12 +233,7 @@ async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, Tot
|
|||||||
TotalChunks: String(TotalChunks),
|
TotalChunks: String(TotalChunks),
|
||||||
FileName,
|
FileName,
|
||||||
});
|
});
|
||||||
const Response = await fetch(`${ApiBase}/upload/chunk?${Params}`, {
|
return PostFormWithProgress(`${ApiBase}/upload/chunk?${Params}`, Form, OnProgress);
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: Form,
|
|
||||||
});
|
|
||||||
return ParseJsonOrThrow(Response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, FileName) {
|
function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, FileName) {
|
||||||
@@ -229,14 +247,16 @@ function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, Fi
|
|||||||
return FetchJson(`/upload/complete?${Params}`, { method: "POST" });
|
return FetchJson(`/upload/complete?${Params}`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function UploadFileChunked(RootName, RelativePath, File) {
|
// OnProgress(BytesSent) counts across all the chunks.
|
||||||
|
export async function UploadFileChunked(RootName, RelativePath, File, OnProgress) {
|
||||||
const UploadId = GenerateUploadId();
|
const UploadId = GenerateUploadId();
|
||||||
const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes));
|
const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes));
|
||||||
|
|
||||||
for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) {
|
for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) {
|
||||||
const Start = ChunkIndex * UploadChunkSizeBytes;
|
const Start = ChunkIndex * UploadChunkSizeBytes;
|
||||||
const ChunkBlob = File.slice(Start, Start + UploadChunkSizeBytes);
|
const ChunkBlob = File.slice(Start, Start + UploadChunkSizeBytes);
|
||||||
await UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, File.name, ChunkBlob);
|
await UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, File.name, ChunkBlob,
|
||||||
|
OnProgress && ((Sent) => OnProgress(Start + Math.min(Sent, ChunkBlob.size))));
|
||||||
}
|
}
|
||||||
|
|
||||||
return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name);
|
return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SettingsPanel from "./Components/SettingsPanel.jsx";
|
|||||||
import GraphPane from "./Components/GraphPane.jsx";
|
import GraphPane from "./Components/GraphPane.jsx";
|
||||||
import LoginScreen from "./Components/LoginScreen.jsx";
|
import LoginScreen from "./Components/LoginScreen.jsx";
|
||||||
import ErrorToast from "./Components/ErrorToast.jsx";
|
import ErrorToast from "./Components/ErrorToast.jsx";
|
||||||
|
import UploadToast from "./Components/UploadToast.jsx";
|
||||||
import {
|
import {
|
||||||
EnsureDirectoryNote,
|
EnsureDirectoryNote,
|
||||||
FetchAuthStatus,
|
FetchAuthStatus,
|
||||||
@@ -429,6 +430,7 @@ export default function App() {
|
|||||||
OnProfileUpdated={HandleProfileUpdated}
|
OnProfileUpdated={HandleProfileUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<UploadToast />
|
||||||
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import {
|
|||||||
FetchLock,
|
FetchLock,
|
||||||
FetchTree,
|
FetchTree,
|
||||||
GetDownloadUrl,
|
GetDownloadUrl,
|
||||||
IsLargeUpload,
|
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
RenameEntry,
|
RenameEntry,
|
||||||
SetLock as SetLockApi,
|
SetLock as SetLockApi,
|
||||||
UploadFileChunked,
|
|
||||||
UploadFiles,
|
|
||||||
} from "../Api.js";
|
} from "../Api.js";
|
||||||
|
import { UploadAll } from "../Upload.js";
|
||||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||||
import AccessModal from "./AccessModal.jsx";
|
import AccessModal from "./AccessModal.jsx";
|
||||||
import PromptModal from "./PromptModal.jsx";
|
import PromptModal from "./PromptModal.jsx";
|
||||||
@@ -183,30 +181,7 @@ export default function DirectoryPane({
|
|||||||
async function HandleUpload(Files) {
|
async function HandleUpload(Files) {
|
||||||
if (!Files || Files.length === 0) return;
|
if (!Files || Files.length === 0) return;
|
||||||
|
|
||||||
const FileArray = Array.from(Files);
|
const Errors = await UploadAll(RootName, DirectoryPath, Files);
|
||||||
const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File));
|
|
||||||
const LargeFiles = FileArray.filter((File) => IsLargeUpload(File));
|
|
||||||
const Errors = [];
|
|
||||||
|
|
||||||
if (SmallFiles.length > 0) {
|
|
||||||
try {
|
|
||||||
const Result = await UploadFiles(RootName, DirectoryPath, SmallFiles);
|
|
||||||
if (Result.Errors && Result.Errors.length > 0) {
|
|
||||||
Errors.push(...Result.Errors);
|
|
||||||
}
|
|
||||||
} catch (Error) {
|
|
||||||
Errors.push(Error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const File of LargeFiles) {
|
|
||||||
try {
|
|
||||||
await UploadFileChunked(RootName, DirectoryPath, File);
|
|
||||||
} catch (Error) {
|
|
||||||
Errors.push(`${File.name}: ${Error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Errors.length > 0) {
|
if (Errors.length > 0) {
|
||||||
SetErrorMessage(Errors.join("; "));
|
SetErrorMessage(Errors.join("; "));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
FetchTree,
|
FetchTree,
|
||||||
GetAvatarUrl,
|
GetAvatarUrl,
|
||||||
GetDownloadUrl,
|
GetDownloadUrl,
|
||||||
IsLargeUpload,
|
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
RenameEntry,
|
RenameEntry,
|
||||||
SetLock as SetLockApi,
|
SetLock as SetLockApi,
|
||||||
UploadFileChunked,
|
|
||||||
UploadFiles,
|
|
||||||
} from "../Api.js";
|
} from "../Api.js";
|
||||||
|
import { UploadAll } from "../Upload.js";
|
||||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||||
import AccessModal from "./AccessModal.jsx";
|
import AccessModal from "./AccessModal.jsx";
|
||||||
import PromptModal from "./PromptModal.jsx";
|
import PromptModal from "./PromptModal.jsx";
|
||||||
@@ -386,30 +384,7 @@ export default function Sidebar({
|
|||||||
async function HandleUpload(Files) {
|
async function HandleUpload(Files) {
|
||||||
if (!Files || Files.length === 0) return;
|
if (!Files || Files.length === 0) return;
|
||||||
|
|
||||||
const FileArray = Array.from(Files);
|
const Errors = await UploadAll(RootName, CurrentDirectoryPath, Files);
|
||||||
const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File));
|
|
||||||
const LargeFiles = FileArray.filter((File) => IsLargeUpload(File));
|
|
||||||
const Errors = [];
|
|
||||||
|
|
||||||
if (SmallFiles.length > 0) {
|
|
||||||
try {
|
|
||||||
const Result = await UploadFiles(RootName, CurrentDirectoryPath, SmallFiles);
|
|
||||||
if (Result.Errors && Result.Errors.length > 0) {
|
|
||||||
Errors.push(...Result.Errors);
|
|
||||||
}
|
|
||||||
} catch (Error) {
|
|
||||||
Errors.push(Error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const File of LargeFiles) {
|
|
||||||
try {
|
|
||||||
await UploadFileChunked(RootName, CurrentDirectoryPath, File);
|
|
||||||
} catch (Error) {
|
|
||||||
Errors.push(`${File.name}: ${Error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Errors.length > 0) {
|
if (Errors.length > 0) {
|
||||||
SetErrorMessage(Errors.join("; "));
|
SetErrorMessage(Errors.join("; "));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React, { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { FormatSize, GetUploadState, SubscribeUploads } from "../Upload.js";
|
||||||
|
|
||||||
|
export default function UploadToast() {
|
||||||
|
const State = useSyncExternalStore(SubscribeUploads, GetUploadState);
|
||||||
|
if (!State) return null;
|
||||||
|
|
||||||
|
const Files = `${State.Count} file${State.Count === 1 ? "" : "s"}`;
|
||||||
|
const Percent = State.Total > 0 ? Math.floor((State.Done / State.Total) * 100) : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="UploadToast" role="status">
|
||||||
|
<span>
|
||||||
|
{State.Finished
|
||||||
|
? `Uploaded ${Files}.`
|
||||||
|
: `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)})`}
|
||||||
|
</span>
|
||||||
|
{!State.Finished && (
|
||||||
|
<div className="UploadToastBar">
|
||||||
|
<div className="UploadToastFill" style={{ width: `${Percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1135,6 +1135,38 @@ img,
|
|||||||
z-index: 200;
|
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 {
|
.ErrorToastClose {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user