feat: visible progress during file upload
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
This commit is contained in:
parent
5aacf17b13
commit
76f35a7f22
4 changed files with 180 additions and 26 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -417,6 +417,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);
|
||||
|
|
@ -573,9 +574,11 @@ export function useFileOperations() {
|
|||
file: globalThis.File,
|
||||
workflowId?: string,
|
||||
featureInstanceId?: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
) => {
|
||||
setUploadError(null);
|
||||
setUploadingFile(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
|
||||
|
|
@ -602,7 +605,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
|
||||
|
|
@ -634,6 +644,7 @@ export function useFileOperations() {
|
|||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -758,6 +769,7 @@ export function useFileOperations() {
|
|||
deletingFiles,
|
||||
editingFiles,
|
||||
uploadingFile,
|
||||
uploadProgress,
|
||||
downloadError,
|
||||
deleteError,
|
||||
uploadError,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ export const FilesPage: React.FC = () => {
|
|||
handleInlineUpdate,
|
||||
deletingFiles,
|
||||
downloadingFiles,
|
||||
uploadingFile,
|
||||
previewingFiles,
|
||||
} = useFileOperations();
|
||||
|
||||
|
|
@ -87,6 +86,8 @@ export const FilesPage: React.FC = () => {
|
|||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
|
||||
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);
|
||||
|
|
@ -303,24 +304,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -475,8 +490,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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue