feat: visible progress during file upload
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s

This commit is contained in:
Ida 2026-05-27 11:18:08 +02:00
parent e4059a6b09
commit 8445cbde4a
4 changed files with 180 additions and 26 deletions

View file

@ -81,6 +81,60 @@
flex-wrap: wrap;
}
.uploadCircleButton {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: #f25843;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.uploadCircleButton:disabled {
cursor: not-allowed;
}
.uploadCircleWrap {
position: relative;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.uploadCircleSvg {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.uploadCircleTrack {
fill: none;
stroke: rgba(242, 88, 67, 0.25);
stroke-width: 2;
}
.uploadCircleProgress {
fill: none;
stroke: #f25843;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset 120ms linear;
}
.uploadCircleText {
font-size: 8px;
font-weight: 700;
line-height: 1;
color: #f25843;
}
@media (prefers-color-scheme: dark) {
.fileRow:hover {
background: rgba(255, 255, 255, 0.05);

View file

@ -28,6 +28,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const uploadRunIdRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const provider = useMemo(() => createFolderFileProvider(), []);
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
uploadRunIdRef.current += 1;
const runId = uploadRunIdRef.current;
setUploading(true);
setUploadProgressPercent(0);
try {
for (const file of Array.from(fileList)) {
const files = Array.from(fileList);
const totalFiles = files.length || 1;
for (const [index, file] of files.entries()) {
const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: progressEvent => {
if (uploadRunIdRef.current !== runId) return;
if (!progressEvent.total) return;
const fileProgress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
},
});
}
if (uploadRunIdRef.current === runId) setUploadProgressPercent(100);
_handleRefresh();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
if (uploadRunIdRef.current === runId) {
setUploading(false);
// Let 100% render briefly, then reset.
window.setTimeout(() => {
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
}, 250);
}
}
}, [context.instanceId, uploading, _handleRefresh]);
@ -135,6 +157,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]);
const circleRadius = 11;
const circleCircumference = 2 * Math.PI * circleRadius;
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
return (
<div
className={styles.filesTab}
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
className={styles.uploadCircleButton}
title={t('Dateien hochladen')}
>
{uploading ? '...' : '+'}
{uploading ? (
<span className={styles.uploadCircleWrap} aria-hidden="true">
<svg className={styles.uploadCircleSvg} viewBox="0 0 24 24">
<circle className={styles.uploadCircleTrack} cx="12" cy="12" r={circleRadius} />
<circle
className={styles.uploadCircleProgress}
cx="12"
cy="12"
r={circleRadius}
style={{ strokeDasharray: `${circleCircumference}`, strokeDashoffset: `${circleOffset}` }}
/>
</svg>
<span className={styles.uploadCircleText}>{uploadProgressPercent}%</span>
</span>
) : (
'+'
)}
</button>
<button
onClick={_handleRefresh}

View file

@ -408,6 +408,7 @@ export function useFileOperations() {
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
const [uploadingFile, setUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@ -564,9 +565,11 @@ export function useFileOperations() {
file: globalThis.File,
workflowId?: string,
featureInstanceId?: string,
onProgress?: (progress: number) => void,
) => {
setUploadError(null);
setUploadingFile(true);
setUploadProgress(0);
try {
@ -593,7 +596,14 @@ export function useFileOperations() {
// Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData
const response = await api.post('/api/files/upload', formData);
const response = await api.post('/api/files/upload', formData, {
onUploadProgress: progressEvent => {
if (!progressEvent.total) return;
const progress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
setUploadProgress(progress);
onProgress?.(progress);
},
});
const fileData = response.data;
// Check if the response indicates a duplicate file
@ -625,6 +635,7 @@ export function useFileOperations() {
return { success: false, error: errorMessage };
} finally {
setUploadingFile(false);
setUploadProgress(0);
}
};
@ -749,6 +760,7 @@ export function useFileOperations() {
deletingFiles,
editingFiles,
uploadingFile,
uploadProgress,
downloadError,
deleteError,
uploadError,

View file

@ -67,7 +67,6 @@ export const FilesPage: React.FC = () => {
handleInlineUpdate,
deletingFiles,
downloadingFiles,
uploadingFile,
previewingFiles,
} = useFileOperations();
@ -75,6 +74,8 @@ export const FilesPage: React.FC = () => {
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
const [treeWidth, setTreeWidth] = useState(300);
const [treeVisible, setTreeVisible] = useState(true);
@ -264,24 +265,38 @@ export const FilesPage: React.FC = () => {
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files;
if (picked && picked.length > 0) {
let successCount = 0;
let errorCount = 0;
for (const file of Array.from(picked)) {
const result = await handleFileUpload(file);
if (result?.success) successCount++; else errorCount++;
}
if (fileInputRef.current) fileInputRef.current.value = '';
await _tableRefetch();
setTreeKey(k => k + 1);
if (successCount > 0) {
showSuccess(
t('Upload erfolgreich'),
errorCount > 0
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
: t('{successCount} Datei(en) hochgeladen', { successCount }),
);
} else if (errorCount > 0) {
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
setIsUploadingBatch(true);
setUploadProgressPercent(0);
try {
let successCount = 0;
let errorCount = 0;
const files = Array.from(picked);
const totalFiles = files.length;
for (const [index, file] of files.entries()) {
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
});
if (result?.success) successCount++; else errorCount++;
}
setUploadProgressPercent(100);
if (fileInputRef.current) fileInputRef.current.value = '';
await _tableRefetch();
setTreeKey(k => k + 1);
if (successCount > 0) {
showSuccess(
t('Upload erfolgreich'),
errorCount > 0
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
: t('{successCount} Datei(en) hochgeladen', { successCount }),
);
} else if (errorCount > 0) {
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
}
} finally {
setIsUploadingBatch(false);
setUploadProgressPercent(0);
}
}
};
@ -436,8 +451,39 @@ export const FilesPage: React.FC = () => {
<div style={{ flex: 1 }} />
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={isUploadingBatch}
style={{ position: 'relative', overflow: 'hidden' }}
>
{isUploadingBatch && (
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${uploadProgressPercent}%`,
background: 'rgba(255, 255, 255, 0.25)',
transition: 'width 120ms linear',
}}
/>
)}
<span
style={{
position: 'relative',
zIndex: 1,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
<FaUpload />
<span>{t('Datei hochladen')}</span>
{isUploadingBatch && <span>{uploadProgressPercent}%</span>}
</span>
</button>
)}
</div>