diff --git a/ui_src/src/components/progress-bar.tsx b/ui_src/src/components/progress-bar.tsx new file mode 100644 index 0000000..a6a8b7f --- /dev/null +++ b/ui_src/src/components/progress-bar.tsx @@ -0,0 +1,62 @@ +import { UploadProgress, formatSpeed, formatTime } from "../upload/progress"; +import { FormatBytes } from "../const"; + +interface ProgressBarProps { + progress: UploadProgress; + fileName?: string; +} + +export default function ProgressBar({ progress, fileName }: ProgressBarProps) { + const { + percentage, + bytesUploaded, + totalBytes, + averageSpeed, + estimatedTimeRemaining, + } = progress; + + return ( +
+
+

+ {fileName ? `Uploading ${fileName}` : "Uploading..."} +

+ + {percentage.toFixed(1)}% + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Upload Stats */} +
+
+ Progress: + + {FormatBytes(bytesUploaded)} / {FormatBytes(totalBytes)} + +
+ +
+ Speed: + + {formatSpeed(averageSpeed)} + +
+ +
+ ETA: + + {formatTime(estimatedTimeRemaining)} + +
+
+
+ ); +} \ 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