loading bar for file uploads
This commit is contained in:
@@ -169,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) {
|
||||
function Flow() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]);
|
||||
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 [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
||||
const [executingNodeId, setExecutingNodeId] = useState<string | null>(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) && (
|
||||
<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 */}
|
||||
|
||||
@@ -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<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() {
|
||||
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) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user