feat: visible progress during file upload
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
This commit is contained in:
parent
e4059a6b09
commit
8445cbde4a
4 changed files with 180 additions and 26 deletions
|
|
@ -81,6 +81,60 @@
|
||||||
flex-wrap: wrap;
|
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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.fileRow:hover {
|
.fileRow:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||||
|
const uploadRunIdRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const provider = useMemo(() => createFolderFileProvider(), []);
|
const provider = useMemo(() => createFolderFileProvider(), []);
|
||||||
|
|
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
|
|
||||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
if (!context.instanceId || uploading) return;
|
if (!context.instanceId || uploading) return;
|
||||||
|
uploadRunIdRef.current += 1;
|
||||||
|
const runId = uploadRunIdRef.current;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadProgressPercent(0);
|
||||||
try {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('featureInstanceId', context.instanceId);
|
formData.append('featureInstanceId', context.instanceId);
|
||||||
await api.post('/api/files/upload', formData, {
|
await api.post('/api/files/upload', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
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();
|
_handleRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('File upload failed:', err);
|
console.error('File upload failed:', err);
|
||||||
} finally {
|
} 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]);
|
}, [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?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
||||||
}, [onSendToChat]);
|
}, [onSendToChat]);
|
||||||
|
|
||||||
|
const circleRadius = 11;
|
||||||
|
const circleCircumference = 2 * Math.PI * circleRadius;
|
||||||
|
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.filesTab}
|
className={styles.filesTab}
|
||||||
|
|
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
className={styles.uploadCircleButton}
|
||||||
title={t('Dateien hochladen')}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={_handleRefresh}
|
onClick={_handleRefresh}
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,7 @@ export function useFileOperations() {
|
||||||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||||
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [isLoading] = useState(false);
|
const [isLoading] = useState(false);
|
||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
@ -564,9 +565,11 @@ export function useFileOperations() {
|
||||||
file: globalThis.File,
|
file: globalThis.File,
|
||||||
workflowId?: string,
|
workflowId?: string,
|
||||||
featureInstanceId?: string,
|
featureInstanceId?: string,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
) => {
|
) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
@ -593,7 +596,14 @@ export function useFileOperations() {
|
||||||
|
|
||||||
|
|
||||||
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
// 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;
|
const fileData = response.data;
|
||||||
|
|
||||||
// Check if the response indicates a duplicate file
|
// Check if the response indicates a duplicate file
|
||||||
|
|
@ -625,6 +635,7 @@ export function useFileOperations() {
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFile(false);
|
setUploadingFile(false);
|
||||||
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -749,6 +760,7 @@ export function useFileOperations() {
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
editingFiles,
|
editingFiles,
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
|
uploadProgress,
|
||||||
downloadError,
|
downloadError,
|
||||||
deleteError,
|
deleteError,
|
||||||
uploadError,
|
uploadError,
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ export const FilesPage: React.FC = () => {
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
uploadingFile,
|
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
|
|
@ -75,6 +74,8 @@ export const FilesPage: React.FC = () => {
|
||||||
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
const [highlightedFileId, setHighlightedFileId] = 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 [treeWidth, setTreeWidth] = useState(300);
|
||||||
const [treeVisible, setTreeVisible] = useState(true);
|
const [treeVisible, setTreeVisible] = useState(true);
|
||||||
|
|
@ -264,24 +265,38 @@ export const FilesPage: React.FC = () => {
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const picked = e.target.files;
|
const picked = e.target.files;
|
||||||
if (picked && picked.length > 0) {
|
if (picked && picked.length > 0) {
|
||||||
let successCount = 0;
|
setIsUploadingBatch(true);
|
||||||
let errorCount = 0;
|
setUploadProgressPercent(0);
|
||||||
for (const file of Array.from(picked)) {
|
try {
|
||||||
const result = await handleFileUpload(file);
|
let successCount = 0;
|
||||||
if (result?.success) successCount++; else errorCount++;
|
let errorCount = 0;
|
||||||
}
|
const files = Array.from(picked);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
const totalFiles = files.length;
|
||||||
await _tableRefetch();
|
for (const [index, file] of files.entries()) {
|
||||||
setTreeKey(k => k + 1);
|
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
|
||||||
if (successCount > 0) {
|
const baseProgress = (index / totalFiles) * 100;
|
||||||
showSuccess(
|
const scaledFileProgress = fileProgress / totalFiles;
|
||||||
t('Upload erfolgreich'),
|
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
||||||
errorCount > 0
|
});
|
||||||
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
if (result?.success) successCount++; else errorCount++;
|
||||||
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
}
|
||||||
);
|
setUploadProgressPercent(100);
|
||||||
} else if (errorCount > 0) {
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
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 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
<button
|
||||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue