loading bar for file uploads
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user