From aba7ea9d79d8d3e345002b3851289533db4a9ef4 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Fri, 13 Feb 2026 22:08:36 +0100 Subject: [PATCH] feat: add file upload progress toast with per-file tracking (#93) Replaced the hidden dropzone-only progress bar with a floating upload toast that appears at the bottom-right corner whenever files are being uploaded (button or drag-and-drop). Features: - Per-file progress bar with real byte-level tracking via XHR - Spinning icon while uploading, green check on success, red on error - Overall progress bar and file counter in the footer - Auto-hides 4 seconds after all uploads complete - Dismiss button to minimise the toast - Works for both file and folder uploads - i18n keys added to all 8 locale files - Service worker cache bumped to v3 --- static/css/style.css | 129 ++++++++++++++ static/index.html | 17 ++ static/js/fileOperations.js | 332 ++++++++++++++++++++++++------------ static/locales/de.json | 2 + static/locales/en.json | 2 + static/locales/es.json | 2 + static/locales/fa.json | 2 + static/locales/fr.json | 2 + static/locales/it.json | 4 +- static/locales/pt.json | 2 + static/locales/zh.json | 2 + static/sw.js | 2 +- 12 files changed, 383 insertions(+), 115 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 674dc995..205dc5f8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -3472,4 +3472,133 @@ html[dir='rtl'] .fa-arrow-left::before { html[dir='rtl'] .fa-sign-out-alt { -webkit-transform: rotate(180deg); transform: rotate(180deg); +} + +/* ============================================================================ + Upload Progress Toast + ============================================================================ */ +.upload-toast { + position: fixed; + bottom: 24px; + right: 24px; + width: 360px; + max-height: 400px; + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0,0,0,0.15); + z-index: 10000; + display: none; + flex-direction: column; + overflow: hidden; + font-family: inherit; + animation: uploadToastSlideIn 0.3s ease-out; +} +.upload-toast.visible { + display: flex; +} +@keyframes uploadToastSlideIn { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.upload-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #ff5e3a; + color: #fff; +} +.upload-toast-title { + font-weight: 600; + font-size: 14px; +} +.upload-toast-close { + background: none; + border: none; + color: #fff; + font-size: 20px; + cursor: pointer; + line-height: 1; + padding: 0 4px; + opacity: 0.8; +} +.upload-toast-close:hover { opacity: 1; } + +.upload-toast-body { + flex: 1; + overflow-y: auto; + padding: 8px 0; + max-height: 260px; +} +.upload-toast-file { + display: flex; + align-items: center; + padding: 6px 16px; + gap: 10px; +} +.upload-toast-file-icon { + font-size: 16px; + color: #999; + flex-shrink: 0; + width: 20px; + text-align: center; +} +.upload-toast-file-icon.done { color: #34c759; } +.upload-toast-file-icon.error { color: #ff3b30; } +.upload-toast-file-info { + flex: 1; + min-width: 0; +} +.upload-toast-file-name { + font-size: 13px; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.upload-toast-file-bar { + height: 3px; + background: #eee; + border-radius: 2px; + margin-top: 4px; + overflow: hidden; +} +.upload-toast-file-fill { + height: 100%; + background: #ff5e3a; + width: 0%; + transition: width 0.2s ease; + border-radius: 2px; +} +.upload-toast-file-fill.done { background: #34c759; } +.upload-toast-file-fill.error { background: #ff3b30; } +.upload-toast-file-pct { + font-size: 12px; + color: #999; + flex-shrink: 0; + width: 38px; + text-align: right; +} + +.upload-toast-footer { + padding: 10px 16px; + border-top: 1px solid #f0f0f0; +} +.upload-toast-overall-bar { + height: 4px; + background: #eee; + border-radius: 2px; + overflow: hidden; + margin-bottom: 6px; +} +.upload-toast-overall-fill { + height: 100%; + background: #ff5e3a; + width: 0%; + transition: width 0.25s ease; + border-radius: 2px; +} +.upload-toast-stats { + font-size: 12px; + color: #888; } \ No newline at end of file diff --git a/static/index.html b/static/index.html index 3f411303..52aa70dd 100644 --- a/static/index.html +++ b/static/index.html @@ -284,5 +284,22 @@ + + +
+
+ Uploading... + +
+
+ +
+ +
diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 1234c819..420c6d3f 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -18,104 +18,217 @@ function getAuthHeaders() { // File Operations Module const fileOps = { + + // ======================================================================== + // Upload progress toast helpers + // ======================================================================== + + /** Show the upload progress toast and reset its contents */ + _initUploadToast(totalFiles) { + const toast = document.getElementById('upload-toast'); + const body = document.getElementById('upload-toast-body'); + const title = document.getElementById('upload-toast-title'); + const stats = document.getElementById('upload-toast-stats'); + const fill = document.getElementById('upload-toast-overall-fill'); + const closeBtn = document.getElementById('upload-toast-close'); + + body.innerHTML = ''; + fill.style.width = '0%'; + const uploadingText = (window.i18n && window.i18n.t) ? window.i18n.t('upload.uploading') : 'Uploading...'; + title.textContent = uploadingText; + stats.textContent = `0 / ${totalFiles}`; + toast.classList.add('visible'); + + // Allow user to minimise (hide) the toast; it will re-appear on next upload + closeBtn.onclick = () => toast.classList.remove('visible'); + }, + + /** Add a file row to the toast and return its element references */ + _addToastFileRow(fileName) { + const body = document.getElementById('upload-toast-body'); + const row = document.createElement('div'); + row.className = 'upload-toast-file'; + row.innerHTML = ` + +
+
${fileName}
+
+
+ 0% + `; + body.appendChild(row); + // Auto-scroll to bottom + body.scrollTop = body.scrollHeight; + return { + row, + icon: row.querySelector('.upload-toast-file-icon'), + fill: row.querySelector('.upload-toast-file-fill'), + pct: row.querySelector('.upload-toast-file-pct'), + }; + }, + + /** Update the overall progress in the toast footer */ + _updateOverallProgress(completedCount, totalFiles) { + const fill = document.getElementById('upload-toast-overall-fill'); + const stats = document.getElementById('upload-toast-stats'); + const pct = totalFiles > 0 ? Math.round((completedCount / totalFiles) * 100) : 0; + fill.style.width = pct + '%'; + stats.textContent = `${completedCount} / ${totalFiles}`; + }, + + /** Mark upload toast as fully complete and auto-hide after a delay */ + _finishUploadToast(successCount, totalFiles) { + const title = document.getElementById('upload-toast-title'); + const fill = document.getElementById('upload-toast-overall-fill'); + fill.style.width = '100%'; + + const completeText = (window.i18n && window.i18n.t) + ? window.i18n.t('upload.complete', { count: successCount, total: totalFiles }) + : `${successCount} / ${totalFiles} uploaded`; + title.textContent = completeText; + + setTimeout(() => { + const toast = document.getElementById('upload-toast'); + toast.classList.remove('visible'); + }, 4000); + }, + /** - * Upload files to the server + * Upload a single file via XMLHttpRequest with progress events. + * Returns a promise that resolves with { ok, data? }. + */ + _uploadFileXHR(formData, fileRowElements) { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable && fileRowElements) { + const pct = Math.round((e.loaded / e.total) * 100); + fileRowElements.fill.style.width = pct + '%'; + fileRowElements.pct.textContent = pct + '%'; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + if (fileRowElements) { + fileRowElements.fill.style.width = '100%'; + fileRowElements.fill.classList.add('done'); + fileRowElements.pct.textContent = '100%'; + fileRowElements.icon.innerHTML = ''; + fileRowElements.icon.classList.add('done'); + } + let data = null; + try { data = JSON.parse(xhr.responseText); } catch (_) {} + resolve({ ok: true, data }); + } else { + if (fileRowElements) { + fileRowElements.fill.classList.add('error'); + fileRowElements.pct.textContent = 'ERR'; + fileRowElements.icon.innerHTML = ''; + fileRowElements.icon.classList.add('error'); + } + resolve({ ok: false }); + } + }); + + xhr.addEventListener('error', () => { + if (fileRowElements) { + fileRowElements.fill.classList.add('error'); + fileRowElements.pct.textContent = 'ERR'; + fileRowElements.icon.innerHTML = ''; + fileRowElements.icon.classList.add('error'); + } + resolve({ ok: false }); + }); + + xhr.open('POST', '/api/files/upload'); + + // Set auth header + const token = localStorage.getItem('oxicloud_token'); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + + xhr.send(formData); + }); + }, + + // ======================================================================== + // Upload files (via button or drag-and-drop) + // ======================================================================== + + /** + * Upload files to the server with real-time progress indication * @param {FileList} files - Files to upload */ async uploadFiles(files) { + const totalFiles = files.length; + if (totalFiles === 0) return; + + // Legacy progress bar (inside dropzone) — keep working for drag-drop const progressBar = document.querySelector('.progress-fill'); const uploadProgressDiv = document.querySelector('.upload-progress'); - uploadProgressDiv.style.display = 'block'; - progressBar.style.width = '0%'; + if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } + if (progressBar) { progressBar.style.width = '0%'; } + + // Show upload toast + this._initUploadToast(totalFiles); let uploadedCount = 0; - const totalFiles = files.length; + let successCount = 0; for (let i = 0; i < totalFiles; i++) { const file = files[i]; const formData = new FormData(); - - // IMPORTANT: folder_id MUST be added BEFORE file for multipart processing - // The backend reads fields in order, and needs folder_id before processing the file + const targetFolderId = window.app.currentPath || window.app.userHomeFolderId; - - if (targetFolderId) { - formData.append('folder_id', targetFolderId); - } - - // Add the file AFTER folder_id + if (targetFolderId) formData.append('folder_id', targetFolderId); formData.append('file', file); - try { - console.log(`Uploading file to folder: ${targetFolderId || 'root'}`); - - // We use the correct URL for file upload - console.log('Form to submit:', { - file: file.name, - size: file.size, - folder_id: targetFolderId || 'root' - }); - - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - // Add cache: 'no-store' to avoid cache issues during upload - cache: 'no-store', - headers: { - ...getAuthHeaders(), - // Add this header to force fresh reloads - 'Cache-Control': 'no-cache, no-store, must-revalidate' - } - }); - - console.log('Server response:', { - status: response.status, - statusText: response.statusText - }); + console.log(`Uploading file to folder: ${targetFolderId || 'root'}`, { + file: file.name, size: file.size + }); - // Update progress - uploadedCount++; - const percentComplete = (uploadedCount / totalFiles) * 100; - progressBar.style.width = percentComplete + '%'; + // Add row to toast + const rowEls = this._addToastFileRow(file.name); - if (response.ok) { - const responseData = await response.json(); - console.log(`Successfully uploaded ${file.name}`, responseData); - - // Show success notification immediately - window.ui.showNotification('File uploaded', `${file.name} completed`); + const result = await this._uploadFileXHR(formData, rowEls); - if (i === totalFiles - 1) { - // Last file uploaded - wait and reload once - console.log('Last file uploaded, waiting before reloading...'); - - // Wait for backend to persist - await new Promise(resolve => setTimeout(resolve, 800)); - - // Single reload with force refresh - try { - await window.loadFiles({forceRefresh: true}); - } catch (reloadError) { - console.error("Error reloading files:", reloadError); - } - - // Hide upload UI - setTimeout(() => { - const dropzone = document.getElementById('dropzone'); - if (dropzone) dropzone.style.display = 'none'; - uploadProgressDiv.style.display = 'none'; - }, 500); - } - } else { - const errorData = await response.text(); - console.error('Upload error:', errorData); - window.ui.showNotification('Error', `Error uploading file: ${file.name}`); - } - } catch (error) { - console.error('Network error during upload:', error); - window.ui.showNotification('Error', `Network error uploading file: ${file.name}`); + uploadedCount++; + + // Legacy dropzone bar + if (progressBar) { + progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; + } + // Toast overall bar + this._updateOverallProgress(uploadedCount, totalFiles); + + if (result.ok) { + successCount++; + console.log(`Successfully uploaded ${file.name}`, result.data); + } else { + console.error(`Upload error for ${file.name}`); + window.ui.showNotification('Error', `Error uploading file: ${file.name}`); } } + + // All done + this._finishUploadToast(successCount, totalFiles); + + // Wait for backend to persist, then reload + await new Promise(resolve => setTimeout(resolve, 800)); + + try { + await window.loadFiles({ forceRefresh: true }); + } catch (reloadError) { + console.error('Error reloading files:', reloadError); + } + + setTimeout(() => { + const dropzone = document.getElementById('dropzone'); + if (dropzone) dropzone.style.display = 'none'; + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + }, 500); }, /** @@ -128,33 +241,29 @@ const fileOps = { const progressBar = document.querySelector('.progress-fill'); const uploadProgressDiv = document.querySelector('.upload-progress'); - uploadProgressDiv.style.display = 'block'; - progressBar.style.width = '0%'; + if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } + if (progressBar) { progressBar.style.width = '0%'; } const currentFolderId = window.app.currentPath || window.app.userHomeFolderId; // Build folder structure from relative paths - // webkitRelativePath looks like: "folderName/subfolder/file.txt" - const folderMap = new Map(); // path -> folder_id - folderMap.set('', currentFolderId); // root = current folder + const folderMap = new Map(); + folderMap.set('', currentFolderId); - // Collect all unique folder paths const folderPaths = new Set(); for (const file of files) { const parts = file.webkitRelativePath.split('/'); - // Remove filename, keep folder parts for (let i = 1; i < parts.length; i++) { const path = parts.slice(0, i).join('/'); folderPaths.add(path); } } - // Sort paths by depth so parents are created first const sortedPaths = [...folderPaths].sort((a, b) => a.split('/').length - b.split('/').length ); - // Create folders + // Create folders first (no progress toast for folder creation) for (const folderPath of sortedPaths) { const parts = folderPath.split('/'); const folderName = parts[parts.length - 1]; @@ -188,9 +297,12 @@ const fileOps = { } } - // Upload files into their respective folders - let uploadedCount = 0; + // Upload files with progress toast const totalFiles = files.length; + this._initUploadToast(totalFiles); + + let uploadedCount = 0; + let successCount = 0; for (let i = 0; i < totalFiles; i++) { const file = files[i]; @@ -201,34 +313,28 @@ const fileOps = { const formData = new FormData(); formData.append('folder_id', targetFolderId); formData.append('file', file); + + const displayName = file.webkitRelativePath || file.name; + const rowEls = this._addToastFileRow(displayName); + + const result = await this._uploadFileXHR(formData, rowEls); - try { - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - cache: 'no-store', - headers: { - ...getAuthHeaders(), - 'Cache-Control': 'no-cache, no-store, must-revalidate' - } - }); + uploadedCount++; + if (progressBar) { + progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; + } + this._updateOverallProgress(uploadedCount, totalFiles); - uploadedCount++; - const percentComplete = (uploadedCount / totalFiles) * 100; - progressBar.style.width = percentComplete + '%'; - - if (response.ok) { - console.log(`Uploaded: ${file.webkitRelativePath}`); - } else { - console.error(`Error uploading ${file.webkitRelativePath}:`, await response.text()); - } - } catch (error) { - console.error(`Network error uploading ${file.webkitRelativePath}:`, error); + if (result.ok) { + successCount++; + console.log(`Uploaded: ${file.webkitRelativePath}`); + } else { + console.error(`Error uploading ${file.webkitRelativePath}`); } } - // Finish up - window.ui.showNotification('Folder uploaded', `${uploadedCount} files uploaded successfully`); + // Finish + this._finishUploadToast(successCount, totalFiles); await new Promise(resolve => setTimeout(resolve, 800)); @@ -241,7 +347,7 @@ const fileOps = { setTimeout(() => { const dropzone = document.getElementById('dropzone'); if (dropzone) dropzone.style.display = 'none'; - uploadProgressDiv.style.display = 'none'; + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; }, 500); }, diff --git a/static/locales/de.json b/static/locales/de.json index 9531453b..b6d242ca 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -16,6 +16,8 @@ "upload": "Hochladen", "upload_files": "Dateien hochladen", "upload_folder": "Ordner hochladen", + "upload.uploading": "Wird hochgeladen...", + "upload.complete": "{count} / {total} hochgeladen", "rename": "Umbenennen", "move": "Verschieben nach...", "move_to": "Verschieben nach", diff --git a/static/locales/en.json b/static/locales/en.json index 9d20c490..584549bf 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -16,6 +16,8 @@ "upload": "Upload", "upload_files": "Upload files", "upload_folder": "Upload folder", + "upload.uploading": "Uploading...", + "upload.complete": "{count} / {total} uploaded", "rename": "Rename", "move": "Move to...", "move_to": "Move to", diff --git a/static/locales/es.json b/static/locales/es.json index 7b7b04a4..0de91c0b 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -135,6 +135,8 @@ "upload": "Subir", "upload_files": "Subir archivos", "upload_folder": "Subir carpeta", + "upload.uploading": "Subiendo...", + "upload.complete": "{count} / {total} subidos", "rename": "Renombrar", "move": "Mover a...", "move_to": "Mover a", diff --git a/static/locales/fa.json b/static/locales/fa.json index 52365fc7..b41b109b 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -16,6 +16,8 @@ "upload": "بارگذاری", "upload_files": "بارگذاری پرونده‌ها", "upload_folder": "بارگذاری پوشه", + "upload.uploading": "...در حال بارگذاری", + "upload.complete": "{count} / {total} بارگذاری شد", "rename": "تغییر نام", "move": "انتقال به...", "move_to": "انتقال به", diff --git a/static/locales/fr.json b/static/locales/fr.json index 2c520b0d..f3e7a341 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -16,6 +16,8 @@ "upload": "Téléverser", "upload_files": "Téléverser des fichiers", "upload_folder": "Téléverser un dossier", + "upload.uploading": "Envoi en cours...", + "upload.complete": "{count} / {total} envoyés", "rename": "Renommer", "move": "Déplacer vers...", "move_to": "Déplacer vers", diff --git a/static/locales/it.json b/static/locales/it.json index 892b6e38..b5e7c2a4 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -16,6 +16,8 @@ "upload": "Carica", "upload_files": "Carica file", "upload_folder": "Carica cartella", + "upload.uploading": "Caricamento...", + "upload.complete": "{count} / {total} caricati", "rename": "Rinomina", "move": "Sposta in...", "move_to": "Sposta in", @@ -311,7 +313,7 @@ "fa": "Persiano", "fr": "Francese", "de": "Tedesco", - "pt": "Portoghese", + "pt": "Portoghese" "it": "Italiano" } }, diff --git a/static/locales/pt.json b/static/locales/pt.json index 86b27e92..ca67b7c3 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -16,6 +16,8 @@ "upload": "Enviar", "upload_files": "Enviar arquivos", "upload_folder": "Enviar pasta", + "upload.uploading": "Enviando...", + "upload.complete": "{count} / {total} enviados", "rename": "Renomear", "move": "Mover para...", "move_to": "Mover para", diff --git a/static/locales/zh.json b/static/locales/zh.json index 5a769ad5..3cef27a1 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -16,6 +16,8 @@ "upload": "上传", "upload_files": "上传文件", "upload_folder": "上传文件夹", + "upload.uploading": "上传中...", + "upload.complete": "{count} / {total} 已上传", "rename": "重命名", "move": "移动到...", "move_to": "移动到", diff --git a/static/sw.js b/static/sw.js index 21dfd5f5..313b4716 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,5 +1,5 @@ // OxiCloud Service Worker -const CACHE_NAME = 'oxicloud-cache-v2'; +const CACHE_NAME = 'oxicloud-cache-v3'; const ASSETS_TO_CACHE = [ '/', '/index.html',