diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7446fcd..30a7f70 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -169,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) { function Flow() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' }); + const [status, setStatus] = useState<{ text: string; level: string; progress?: number | null }>({ text: 'Connecting…', level: 'info' }); const [contextMenu, setContextMenu] = useState(null); const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); const [executingNodeId, setExecutingNodeId] = useState(null); @@ -983,9 +983,17 @@ function Flow() { setStatus({ text: `Uploading ${entry.file.name}…`, level: 'info', + progress: 0, }); - const uploaded = await api.uploadFile(entry.file, { relativePath: entry.relativePath }); + const uploaded = await api.uploadFile(entry.file, { + relativePath: entry.relativePath, + onProgress: (pct) => setStatus({ + text: `Uploading ${entry.file.name}… ${Math.round(pct * 100)}%`, + level: 'info', + progress: pct, + }), + }); return uploaded.path; }, []); @@ -1052,11 +1060,27 @@ function Flow() { } } + const total = toUpload.size; + let index = 0; for (const uri of toUpload) { const file = pending.get(uri)!; const relativePath = uri.replace(/^session:\/\/uploads\//, ''); - await api.uploadFile(file, { relativePath }); + const fileIndex = index; + setStatus({ + text: `Uploading ${file.name} (${fileIndex + 1}/${total})…`, + level: 'info', + progress: fileIndex / total, + }); + await api.uploadFile(file, { + relativePath, + onProgress: (pct) => setStatus({ + text: `Uploading ${file.name} (${fileIndex + 1}/${total})… ${Math.round(pct * 100)}%`, + level: 'info', + progress: (fileIndex + pct) / total, + }), + }); pending.delete(uri); + index++; } }, []); @@ -1776,9 +1800,15 @@ function Flow() { input.onchange = async (e: Event) => { const file = (e.target as HTMLInputElement)?.files?.[0]; if (!file) return; - setStatus({ text: 'Uploading plugin…', level: 'info' }); + setStatus({ text: 'Uploading plugin…', level: 'info', progress: 0 }); try { - await api.uploadPlugin(file); + await api.uploadPlugin(file, { + onProgress: (pct) => setStatus({ + text: `Uploading plugin… ${Math.round(pct * 100)}%`, + level: 'info', + progress: pct, + }), + }); // Node list refresh is handled by the nodes_updated WebSocket message. } catch (err: any) { setStatus({ text: err.message, level: 'error' }); @@ -2283,10 +2313,15 @@ function Flow() { return () => document.removeEventListener('pointerdown', handler); }, [menuOpen]); - // Auto-dismiss status toast after 5 seconds with close animation + // Auto-dismiss status toast after 5 seconds with close animation. + // Uploads in progress (progress < 1) pause the timer so the bar stays visible. const [toastClosing, setToastClosing] = useState(false); useEffect(() => { if (!status.text) return; + if (status.progress != null && status.progress < 1) { + setToastClosing(false); + return; + } setToastClosing(false); const fadeTimer = setTimeout(() => setToastClosing(true), 4700); const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000); @@ -2563,7 +2598,17 @@ function Flow() { {/* Status toast */} {(status.text || toastClosing) && ( -
{status.text}
+
+
{status.text}
+ {status.progress != null && ( +
+
+
+ )} +
)} {/* React Flow canvas */} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0dd05b3..8bf32fc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -56,6 +56,34 @@ async function sessionFetch(input: string, init?: RequestInit) { return fetch(input, withSessionHeaders(init)); } +/** + * XHR wrapper used for file uploads. Unlike fetch(), XHR exposes upload + * progress events, which the toast UI uses to draw a progress bar. + */ +function xhrRequest( + method: string, + url: string, + body: XMLHttpRequestBodyInit | null, + { + headers = {}, + onProgress, + }: { headers?: Record; onProgress?: (fraction: number) => void } = {}, +): Promise<{ status: number; text: string }> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); + if (onProgress && xhr.upload) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total); + }; + } + xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText }); + xhr.onerror = () => reject(new Error('Network error')); + xhr.send(body); + }); +} + export async function getNodes() { const r = await sessionFetch('/nodes'); if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`); @@ -84,30 +112,40 @@ export async function createUploadFolder(relativePath: string) { return r.json(); } -export async function uploadFile(file: File, { relativePath = '' } = {}) { +export async function uploadFile( + file: File, + { + relativePath = '', + onProgress, + }: { relativePath?: string; onProgress?: (fraction: number) => void } = {}, +) { const fd = new FormData(); if (relativePath) fd.append('relative_path', relativePath); fd.append('file', file); - const r = await sessionFetch('/upload', { method: 'POST', body: fd }); - if (!r.ok) { - const text = await r.text(); - throw new Error(`Upload failed (${r.status}): ${text}`); + const { status, text } = await xhrRequest('POST', '/upload', fd, { + headers: { 'X-Argonode-Session': getSessionId() }, + onProgress, + }); + if (status < 200 || status >= 300) { + throw new Error(`Upload failed (${status}): ${text}`); } - return r.json(); + try { return JSON.parse(text); } catch { return {}; } } -export async function uploadPlugin(file: File) { +export async function uploadPlugin( + file: File, + { onProgress }: { onProgress?: (fraction: number) => void } = {}, +) { const fd = new FormData(); fd.append('file', file); - const r = await fetch('/upload-plugin', { method: 'POST', body: fd }); - if (r.status === 404) { + const { status, text } = await xhrRequest('POST', '/upload-plugin', fd, { onProgress }); + if (status === 404) { throw new Error('Plugin upload is not available in this build.'); } - if (!r.ok) { - const text = await r.text(); - throw new Error(text || `Upload failed (${r.status})`); + if (status < 200 || status >= 300) { + throw new Error(text || `Upload failed (${status})`); } - return r.json(); + try { return JSON.parse(text); } catch { return {}; } } export async function getChannels(filepath: string) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b6f75d0..ba2d639 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -497,8 +497,12 @@ html, body, #root { background: var(--bg-toolbar); border: 1px solid var(--border-toolbar); max-width: 60%; + min-width: 240px; text-align: center; animation: toast-in 0.2s ease-out; + display: flex; + flex-direction: column; + gap: 5px; } .status-toast.closing { animation: toast-out 0.3s ease-in forwards; @@ -506,6 +510,19 @@ html, body, #root { .status-toast.info { color: var(--accent-light); } .status-toast.error { color: var(--error-text); background: var(--error-bg); } +.status-toast-progress { + height: 3px; + width: 100%; + background: rgba(127, 127, 127, 0.22); + border-radius: 2px; + overflow: hidden; +} +.status-toast-progress-fill { + height: 100%; + background: var(--accent-light); + transition: width 0.12s linear; +} + @keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); }