mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
1 parent
db81507b7c
commit
843ad1986e
9 files changed
+259
-78
No files matched your search
+36
-16
@@ -171,18 +171,41 @@ export function GetDownloadUrl(RootName, RelativePath) {
|
||||
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();
|
||||
for (const File of Files) {
|
||||
Form.append("Files", File);
|
||||
}
|
||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||
const Response = await fetch(`${ApiBase}/upload?${Params}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: Form,
|
||||
});
|
||||
return ParseJsonOrThrow(Response);
|
||||
return PostFormWithProgress(`${ApiBase}/upload?${Params}`, Form, OnProgress);
|
||||
}
|
||||
|
||||
const UploadChunkSizeBytes = 20 * 1024 * 1024;
|
||||
@@ -199,7 +222,7 @@ function GenerateUploadId() {
|
||||
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();
|
||||
Form.append("Chunk", ChunkBlob, "chunk");
|
||||
const Params = new URLSearchParams({
|
||||
@@ -210,12 +233,7 @@ async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, Tot
|
||||
TotalChunks: String(TotalChunks),
|
||||
FileName,
|
||||
});
|
||||
const Response = await fetch(`${ApiBase}/upload/chunk?${Params}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: Form,
|
||||
});
|
||||
return ParseJsonOrThrow(Response);
|
||||
return PostFormWithProgress(`${ApiBase}/upload/chunk?${Params}`, Form, OnProgress);
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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 TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes));
|
||||
|
||||
for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) {
|
||||
const Start = ChunkIndex * 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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import SettingsPanel from "./Components/SettingsPanel.jsx";
|
||||
import GraphPane from "./Components/GraphPane.jsx";
|
||||
import LoginScreen from "./Components/LoginScreen.jsx";
|
||||
import ErrorToast from "./Components/ErrorToast.jsx";
|
||||
import UploadToast from "./Components/UploadToast.jsx";
|
||||
import {
|
||||
EnsureDirectoryNote,
|
||||
FetchAuthStatus,
|
||||
@@ -429,6 +430,7 @@ export default function App() {
|
||||
OnProfileUpdated={HandleProfileUpdated}
|
||||
/>
|
||||
)}
|
||||
<UploadToast />
|
||||
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
FetchLock,
|
||||
FetchTree,
|
||||
GetDownloadUrl,
|
||||
IsLargeUpload,
|
||||
MoveEntry,
|
||||
RenameEntry,
|
||||
SetLock as SetLockApi,
|
||||
UploadFileChunked,
|
||||
UploadFiles,
|
||||
} from "../Api.js";
|
||||
import { UploadAll } from "../Upload.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
import AccessModal from "./AccessModal.jsx";
|
||||
import PromptModal from "./PromptModal.jsx";
|
||||
@@ -183,30 +181,7 @@ export default function DirectoryPane({
|
||||
async function HandleUpload(Files) {
|
||||
if (!Files || Files.length === 0) return;
|
||||
|
||||
const FileArray = Array.from(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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const Errors = await UploadAll(RootName, DirectoryPath, Files);
|
||||
if (Errors.length > 0) {
|
||||
SetErrorMessage(Errors.join("; "));
|
||||
}
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
FetchTree,
|
||||
GetAvatarUrl,
|
||||
GetDownloadUrl,
|
||||
IsLargeUpload,
|
||||
MoveEntry,
|
||||
RenameEntry,
|
||||
SetLock as SetLockApi,
|
||||
UploadFileChunked,
|
||||
UploadFiles,
|
||||
} from "../Api.js";
|
||||
import { UploadAll } from "../Upload.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
import AccessModal from "./AccessModal.jsx";
|
||||
import PromptModal from "./PromptModal.jsx";
|
||||
@@ -386,30 +384,7 @@ export default function Sidebar({
|
||||
async function HandleUpload(Files) {
|
||||
if (!Files || Files.length === 0) return;
|
||||
|
||||
const FileArray = Array.from(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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const Errors = await UploadAll(RootName, CurrentDirectoryPath, Files);
|
||||
if (Errors.length > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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