loading bar for file uploads

This commit is contained in:
2026-04-04 23:26:30 -07:00
parent b8d5c11ee9
commit c6096b53a8
3 changed files with 120 additions and 20 deletions

View File

@@ -169,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) {
function Flow() { function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]); const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<TonoEdge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<TonoEdge>([]);
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<any>(null); const [contextMenu, setContextMenu] = useState<any>(null);
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
const [executingNodeId, setExecutingNodeId] = useState<string | null>(null); const [executingNodeId, setExecutingNodeId] = useState<string | null>(null);
@@ -983,9 +983,17 @@ function Flow() {
setStatus({ setStatus({
text: `Uploading ${entry.file.name}`, text: `Uploading ${entry.file.name}`,
level: 'info', 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; return uploaded.path;
}, []); }, []);
@@ -1052,11 +1060,27 @@ function Flow() {
} }
} }
const total = toUpload.size;
let index = 0;
for (const uri of toUpload) { for (const uri of toUpload) {
const file = pending.get(uri)!; const file = pending.get(uri)!;
const relativePath = uri.replace(/^session:\/\/uploads\//, ''); 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); pending.delete(uri);
index++;
} }
}, []); }, []);
@@ -1776,9 +1800,15 @@ function Flow() {
input.onchange = async (e: Event) => { input.onchange = async (e: Event) => {
const file = (e.target as HTMLInputElement)?.files?.[0]; const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) return; if (!file) return;
setStatus({ text: 'Uploading plugin…', level: 'info' }); setStatus({ text: 'Uploading plugin…', level: 'info', progress: 0 });
try { 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. // Node list refresh is handled by the nodes_updated WebSocket message.
} catch (err: any) { } catch (err: any) {
setStatus({ text: err.message, level: 'error' }); setStatus({ text: err.message, level: 'error' });
@@ -2283,10 +2313,15 @@ function Flow() {
return () => document.removeEventListener('pointerdown', handler); return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]); }, [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); const [toastClosing, setToastClosing] = useState(false);
useEffect(() => { useEffect(() => {
if (!status.text) return; if (!status.text) return;
if (status.progress != null && status.progress < 1) {
setToastClosing(false);
return;
}
setToastClosing(false); setToastClosing(false);
const fadeTimer = setTimeout(() => setToastClosing(true), 4700); const fadeTimer = setTimeout(() => setToastClosing(true), 4700);
const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000); const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000);
@@ -2563,7 +2598,17 @@ function Flow() {
{/* Status toast */} {/* Status toast */}
{(status.text || toastClosing) && ( {(status.text || toastClosing) && (
<div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>{status.text}</div> <div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>
<div className="status-toast-text">{status.text}</div>
{status.progress != null && (
<div className="status-toast-progress">
<div
className="status-toast-progress-fill"
style={{ width: `${Math.max(0, Math.min(1, status.progress)) * 100}%` }}
/>
</div>
)}
</div>
)} )}
{/* React Flow canvas */} {/* React Flow canvas */}

View File

@@ -56,6 +56,34 @@ async function sessionFetch(input: string, init?: RequestInit) {
return fetch(input, withSessionHeaders(init)); 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<string, string>; 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() { export async function getNodes() {
const r = await sessionFetch('/nodes'); const r = await sessionFetch('/nodes');
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`); if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
@@ -84,30 +112,40 @@ export async function createUploadFolder(relativePath: string) {
return r.json(); 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(); const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath); if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file); fd.append('file', file);
const r = await sessionFetch('/upload', { method: 'POST', body: fd }); const { status, text } = await xhrRequest('POST', '/upload', fd, {
if (!r.ok) { headers: { 'X-Argonode-Session': getSessionId() },
const text = await r.text(); onProgress,
throw new Error(`Upload failed (${r.status}): ${text}`); });
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(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const r = await fetch('/upload-plugin', { method: 'POST', body: fd }); const { status, text } = await xhrRequest('POST', '/upload-plugin', fd, { onProgress });
if (r.status === 404) { if (status === 404) {
throw new Error('Plugin upload is not available in this build.'); throw new Error('Plugin upload is not available in this build.');
} }
if (!r.ok) { if (status < 200 || status >= 300) {
const text = await r.text(); throw new Error(text || `Upload failed (${status})`);
throw new Error(text || `Upload failed (${r.status})`);
} }
return r.json(); try { return JSON.parse(text); } catch { return {}; }
} }
export async function getChannels(filepath: string) { export async function getChannels(filepath: string) {

View File

@@ -497,8 +497,12 @@ html, body, #root {
background: var(--bg-toolbar); background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar); border: 1px solid var(--border-toolbar);
max-width: 60%; max-width: 60%;
min-width: 240px;
text-align: center; text-align: center;
animation: toast-in 0.2s ease-out; animation: toast-in 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 5px;
} }
.status-toast.closing { .status-toast.closing {
animation: toast-out 0.3s ease-in forwards; animation: toast-out 0.3s ease-in forwards;
@@ -506,6 +510,19 @@ html, body, #root {
.status-toast.info { color: var(--accent-light); } .status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); } .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 { @keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); } from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 1; transform: translateX(-50%) translateY(0); }