+ );
+}
\ No newline at end of file
diff --git a/ui_src/src/upload/blossom.ts b/ui_src/src/upload/blossom.ts
index 8b17676..9b130d0 100644
--- a/ui_src/src/upload/blossom.ts
+++ b/ui_src/src/upload/blossom.ts
@@ -1,6 +1,7 @@
import { base64, bytesToString } from "@scure/base";
import { throwIfOffline, unixNow } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
+import { UploadProgressCallback, uploadWithProgress } from "./progress";
export interface BlobDescriptor {
url?: string;
@@ -28,14 +29,14 @@ export class Blossom {
}
}
- async upload(file: File): Promise {
+ async upload(file: File, onProgress?: UploadProgressCallback): Promise {
const hash = await window.crypto.subtle.digest(
"SHA-256",
await file.arrayBuffer(),
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
- const rsp = await this.#req("upload", "PUT", "upload", file, tags);
+ const rsp = await this.#req("upload", "PUT", "upload", file, tags, undefined, onProgress);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@@ -44,14 +45,14 @@ export class Blossom {
}
}
- async media(file: File): Promise {
+ async media(file: File, onProgress?: UploadProgressCallback): Promise {
const hash = await window.crypto.subtle.digest(
"SHA-256",
await file.arrayBuffer(),
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
- const rsp = await this.#req("media", "PUT", "media", file, tags);
+ const rsp = await this.#req("media", "PUT", "media", file, tags, undefined, onProgress);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@@ -106,6 +107,7 @@ export class Blossom {
body?: BodyInit,
tags?: Array>,
headers?: Record,
+ onProgress?: UploadProgressCallback,
) {
throwIfOffline();
@@ -126,14 +128,22 @@ export class Blossom {
)}`;
};
+ const requestHeaders = {
+ ...headers,
+ accept: "application/json",
+ authorization: await auth(url, method),
+ };
+
+ // Use progress-enabled upload for PUT requests with body
+ if (method === "PUT" && body && onProgress) {
+ return await uploadWithProgress(url, method, body, requestHeaders, onProgress);
+ }
+
+ // Fall back to regular fetch for other requests
return await fetch(url, {
method,
body,
- headers: {
- ...headers,
- accept: "application/json",
- authorization: await auth(url, method),
- },
+ headers: requestHeaders,
});
}
}
diff --git a/ui_src/src/upload/nip96.ts b/ui_src/src/upload/nip96.ts
index ecf52c0..dffe239 100644
--- a/ui_src/src/upload/nip96.ts
+++ b/ui_src/src/upload/nip96.ts
@@ -1,6 +1,7 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
+import { UploadProgressCallback, uploadWithProgress } from "./progress";
export class Nip96 {
#info?: Nip96Info;
@@ -28,14 +29,14 @@ export class Nip96 {
return data;
}
- async upload(file: File) {
+ async upload(file: File, onProgress?: UploadProgressCallback) {
const fd = new FormData();
fd.append("size", file.size.toString());
fd.append("caption", file.name);
fd.append("content_type", file.type);
fd.append("file", file);
- const rsp = await this.#req("", "POST", fd);
+ const rsp = await this.#req("", "POST", fd, onProgress);
const data = await this.#handleResponse(rsp);
if (data.status !== "success") {
throw new Error(data.message);
@@ -57,7 +58,7 @@ export class Nip96 {
}
}
- async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) {
+ async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit, onProgress?: UploadProgressCallback) {
throwIfOffline();
const auth = async (url: string, method: string) => {
const auth = await this.publisher.generic((eb) => {
@@ -77,13 +78,22 @@ export class Nip96 {
u = `${this.url}${u.slice(1)}`;
}
u += path;
+
+ const requestHeaders = {
+ accept: "application/json",
+ authorization: await auth(u, method),
+ };
+
+ // Use progress-enabled upload for POST requests with FormData
+ if (method === "POST" && body && onProgress) {
+ return await uploadWithProgress(u, method, body, requestHeaders, onProgress);
+ }
+
+ // Fall back to regular fetch for other requests
return await fetch(u, {
method,
body,
- headers: {
- accept: "application/json",
- authorization: await auth(u, method),
- },
+ headers: requestHeaders,
});
}
}
diff --git a/ui_src/src/upload/progress.ts b/ui_src/src/upload/progress.ts
new file mode 100644
index 0000000..adfa63a
--- /dev/null
+++ b/ui_src/src/upload/progress.ts
@@ -0,0 +1,184 @@
+// Upload progress tracking types and utilities
+
+export interface UploadProgress {
+ percentage: number;
+ bytesUploaded: number;
+ totalBytes: number;
+ averageSpeed: number; // bytes per second
+ estimatedTimeRemaining: number; // seconds
+ startTime: number;
+}
+
+export interface UploadProgressCallback {
+ (progress: UploadProgress): void;
+}
+
+export class ProgressTracker {
+ private startTime: number;
+ private lastUpdateTime: number;
+ private bytesUploaded: number = 0;
+ private totalBytes: number;
+ private speedSamples: number[] = [];
+ private maxSamples = 10; // Keep last 10 speed samples for averaging
+
+ constructor(totalBytes: number) {
+ this.totalBytes = totalBytes;
+ this.startTime = Date.now();
+ this.lastUpdateTime = this.startTime;
+ }
+
+ update(bytesUploaded: number): UploadProgress {
+ const now = Date.now();
+ const timeDiff = now - this.lastUpdateTime;
+
+ // Calculate instantaneous speed
+ if (timeDiff > 0) {
+ const bytesDiff = bytesUploaded - this.bytesUploaded;
+ const instantSpeed = (bytesDiff / timeDiff) * 1000; // bytes per second
+
+ // Keep a rolling average of speed samples
+ this.speedSamples.push(instantSpeed);
+ if (this.speedSamples.length > this.maxSamples) {
+ this.speedSamples.shift();
+ }
+ }
+
+ this.bytesUploaded = bytesUploaded;
+ this.lastUpdateTime = now;
+
+ // Calculate average speed
+ const averageSpeed = this.speedSamples.length > 0
+ ? this.speedSamples.reduce((sum, speed) => sum + speed, 0) / this.speedSamples.length
+ : 0;
+
+ // Calculate estimated time remaining
+ const remainingBytes = this.totalBytes - bytesUploaded;
+ const estimatedTimeRemaining = averageSpeed > 0 ? remainingBytes / averageSpeed : 0;
+
+ return {
+ percentage: (bytesUploaded / this.totalBytes) * 100,
+ bytesUploaded,
+ totalBytes: this.totalBytes,
+ averageSpeed,
+ estimatedTimeRemaining,
+ startTime: this.startTime,
+ };
+ }
+}
+
+// Utility function to format speed for display
+export function formatSpeed(bytesPerSecond: number): string {
+ if (bytesPerSecond === 0) return "0 B/s";
+
+ const units = ["B/s", "KB/s", "MB/s", "GB/s"];
+ let value = bytesPerSecond;
+ let unitIndex = 0;
+
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex++;
+ }
+
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
+}
+
+// Utility function to format time for display
+export function formatTime(seconds: number): string {
+ if (seconds === 0 || !isFinite(seconds)) return "--";
+
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.floor(seconds % 60);
+
+ if (minutes > 0) {
+ return `${minutes}m ${remainingSeconds}s`;
+ } else {
+ return `${remainingSeconds}s`;
+ }
+}
+
+// XMLHttpRequest wrapper with progress tracking
+export function uploadWithProgress(
+ url: string,
+ method: string,
+ body: BodyInit | null,
+ headers: Record,
+ onProgress?: UploadProgressCallback,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ // Determine total size
+ let totalSize = 0;
+ if (body instanceof File) {
+ totalSize = body.size;
+ } else if (body instanceof FormData) {
+ // For FormData, we need to estimate size
+ const formData = body as FormData;
+ for (const [, value] of formData.entries()) {
+ if (value instanceof File) {
+ totalSize += value.size;
+ } else if (typeof value === 'string') {
+ totalSize += new Blob([value]).size;
+ }
+ }
+ }
+
+ const tracker = new ProgressTracker(totalSize);
+
+ // Set up progress tracking
+ if (onProgress && totalSize > 0) {
+ xhr.upload.addEventListener('progress', (event) => {
+ if (event.lengthComputable) {
+ const progress = tracker.update(event.loaded);
+ onProgress(progress);
+ }
+ });
+ }
+
+ // Set up response handling
+ xhr.addEventListener('load', () => {
+ const response = new Response(xhr.response, {
+ status: xhr.status,
+ statusText: xhr.statusText,
+ headers: parseHeaders(xhr.getAllResponseHeaders()),
+ });
+ resolve(response);
+ });
+
+ xhr.addEventListener('error', () => {
+ reject(new Error('Network error'));
+ });
+
+ xhr.addEventListener('abort', () => {
+ reject(new Error('Upload aborted'));
+ });
+
+ // Configure request
+ xhr.open(method, url);
+
+ // Set headers
+ for (const [key, value] of Object.entries(headers)) {
+ xhr.setRequestHeader(key, value);
+ }
+
+ // Send request
+ xhr.send(body as XMLHttpRequestBodyInit | Document | null);
+ });
+}
+
+// Helper function to parse response headers
+function parseHeaders(headerString: string): Headers {
+ const headers = new Headers();
+ const lines = headerString.trim().split('\r\n');
+
+ for (const line of lines) {
+ const colonIndex = line.indexOf(':');
+ if (colonIndex > 0) {
+ const name = line.substring(0, colonIndex).trim();
+ const value = line.substring(colonIndex + 1).trim();
+ headers.append(name, value);
+ }
+ }
+
+ return headers;
+}
\ No newline at end of file
diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx
index 9510e5a..eaadc8c 100644
--- a/ui_src/src/views/upload.tsx
+++ b/ui_src/src/views/upload.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react";
import Button from "../components/button";
import FileList from "./files";
import PaymentFlow from "../components/payment";
+import ProgressBar from "../components/progress-bar";
import { openFile } from "../upload";
import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login";
@@ -9,6 +10,7 @@ import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96";
import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const";
+import { UploadProgress } from "../upload/progress";
export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("blossom");
@@ -20,6 +22,8 @@ export default function Upload() {
const [listedFiles, setListedFiles] = useState();
const [listedPage, setListedPage] = useState(0);
const [showPaymentFlow, setShowPaymentFlow] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState();
const login = useLogin();
const pub = usePublisher();
@@ -29,19 +33,28 @@ export default function Upload() {
async function doUpload() {
if (!pub) return;
if (!toUpload) return;
+ if (isUploading) return; // Prevent multiple uploads
+
try {
setError(undefined);
+ setIsUploading(true);
+ setUploadProgress(undefined);
+
+ const onProgress = (progress: UploadProgress) => {
+ setUploadProgress(progress);
+ };
+
if (type === "blossom") {
const uploader = new Blossom(url, pub);
const result = noCompress
- ? await uploader.upload(toUpload)
- : await uploader.media(toUpload);
+ ? await uploader.upload(toUpload, onProgress)
+ : await uploader.media(toUpload, onProgress);
setResults((s) => [...s, result]);
}
if (type === "nip96") {
const uploader = new Nip96(url, pub);
await uploader.loadInfo();
- const result = await uploader.upload(toUpload);
+ const result = await uploader.upload(toUpload, onProgress);
setResults((s) => [...s, result]);
}
} catch (e) {
@@ -52,6 +65,9 @@ export default function Upload() {
} else {
setError("Upload failed");
}
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(undefined);
}
}
@@ -184,6 +200,14 @@ export default function Upload() {
)}
+ {/* Upload Progress */}
+ {isUploading && uploadProgress && (
+
+ )}
+
diff --git a/ui_src/tsconfig.app.tsbuildinfo b/ui_src/tsconfig.app.tsbuildinfo
index f63ed5c..e7b712f 100644
--- a/ui_src/tsconfig.app.tsbuildinfo
+++ b/ui_src/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"}
\ No newline at end of file