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
This commit is contained in:
Dionisio
2026-02-13 22:08:36 +01:00
parent 40bf43b292
commit aba7ea9d79
12 changed files with 383 additions and 115 deletions
+129
View File
@@ -3473,3 +3473,132 @@ html[dir='rtl'] .fa-sign-out-alt {
-webkit-transform: rotate(180deg); -webkit-transform: rotate(180deg);
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;
}
+17
View File
@@ -284,5 +284,22 @@
<button class="about-close-btn" id="about-close-btn" data-i18n="actions.close">Close</button> <button class="about-close-btn" id="about-close-btn" data-i18n="actions.close">Close</button>
</div> </div>
</div> </div>
<!-- Upload Progress Toast -->
<div class="upload-toast" id="upload-toast">
<div class="upload-toast-header">
<span class="upload-toast-title" id="upload-toast-title">Uploading...</span>
<button class="upload-toast-close" id="upload-toast-close" title="Minimize">&minus;</button>
</div>
<div class="upload-toast-body" id="upload-toast-body">
<!-- File entries will be added dynamically -->
</div>
<div class="upload-toast-footer">
<div class="upload-toast-overall-bar">
<div class="upload-toast-overall-fill" id="upload-toast-overall-fill"></div>
</div>
<span class="upload-toast-stats" id="upload-toast-stats"></span>
</div>
</div>
</body> </body>
</html> </html>
+199 -93
View File
@@ -18,104 +18,217 @@ function getAuthHeaders() {
// File Operations Module // File Operations Module
const fileOps = { 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 = `
<span class="upload-toast-file-icon"><i class="fas fa-spinner fa-spin"></i></span>
<div class="upload-toast-file-info">
<div class="upload-toast-file-name" title="${fileName}">${fileName}</div>
<div class="upload-toast-file-bar"><div class="upload-toast-file-fill"></div></div>
</div>
<span class="upload-toast-file-pct">0%</span>
`;
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 = '<i class="fas fa-check-circle"></i>';
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 = '<i class="fas fa-exclamation-circle"></i>';
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 = '<i class="fas fa-exclamation-circle"></i>';
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 * @param {FileList} files - Files to upload
*/ */
async uploadFiles(files) { 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 progressBar = document.querySelector('.progress-fill');
const uploadProgressDiv = document.querySelector('.upload-progress'); const uploadProgressDiv = document.querySelector('.upload-progress');
uploadProgressDiv.style.display = 'block'; if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
progressBar.style.width = '0%'; if (progressBar) { progressBar.style.width = '0%'; }
// Show upload toast
this._initUploadToast(totalFiles);
let uploadedCount = 0; let uploadedCount = 0;
const totalFiles = files.length; let successCount = 0;
for (let i = 0; i < totalFiles; i++) { for (let i = 0; i < totalFiles; i++) {
const file = files[i]; const file = files[i];
const formData = new FormData(); 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; const targetFolderId = window.app.currentPath || window.app.userHomeFolderId;
if (targetFolderId) formData.append('folder_id', targetFolderId);
if (targetFolderId) {
formData.append('folder_id', targetFolderId);
}
// Add the file AFTER folder_id
formData.append('file', file); formData.append('file', file);
try { console.log(`Uploading file to folder: ${targetFolderId || 'root'}`, {
console.log(`Uploading file to folder: ${targetFolderId || 'root'}`); file: file.name, size: file.size
// 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', { // Add row to toast
method: 'POST', const rowEls = this._addToastFileRow(file.name);
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:', { const result = await this._uploadFileXHR(formData, rowEls);
status: response.status,
statusText: response.statusText
});
// Update progress
uploadedCount++; uploadedCount++;
const percentComplete = (uploadedCount / totalFiles) * 100;
progressBar.style.width = percentComplete + '%';
if (response.ok) { // Legacy dropzone bar
const responseData = await response.json(); if (progressBar) {
console.log(`Successfully uploaded ${file.name}`, responseData); progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
}
// Toast overall bar
this._updateOverallProgress(uploadedCount, totalFiles);
// Show success notification immediately if (result.ok) {
window.ui.showNotification('File uploaded', `${file.name} completed`); 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}`);
}
}
if (i === totalFiles - 1) { // All done
// Last file uploaded - wait and reload once this._finishUploadToast(successCount, totalFiles);
console.log('Last file uploaded, waiting before reloading...');
// Wait for backend to persist // Wait for backend to persist, then reload
await new Promise(resolve => setTimeout(resolve, 800)); await new Promise(resolve => setTimeout(resolve, 800));
// Single reload with force refresh
try { try {
await window.loadFiles({ forceRefresh: true }); await window.loadFiles({ forceRefresh: true });
} catch (reloadError) { } catch (reloadError) {
console.error("Error reloading files:", reloadError); console.error('Error reloading files:', reloadError);
} }
// Hide upload UI
setTimeout(() => { setTimeout(() => {
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
if (dropzone) dropzone.style.display = 'none'; if (dropzone) dropzone.style.display = 'none';
uploadProgressDiv.style.display = 'none'; if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
}, 500); }, 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}`);
}
}
}, },
/** /**
@@ -128,33 +241,29 @@ const fileOps = {
const progressBar = document.querySelector('.progress-fill'); const progressBar = document.querySelector('.progress-fill');
const uploadProgressDiv = document.querySelector('.upload-progress'); const uploadProgressDiv = document.querySelector('.upload-progress');
uploadProgressDiv.style.display = 'block'; if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
progressBar.style.width = '0%'; if (progressBar) { progressBar.style.width = '0%'; }
const currentFolderId = window.app.currentPath || window.app.userHomeFolderId; const currentFolderId = window.app.currentPath || window.app.userHomeFolderId;
// Build folder structure from relative paths // Build folder structure from relative paths
// webkitRelativePath looks like: "folderName/subfolder/file.txt" const folderMap = new Map();
const folderMap = new Map(); // path -> folder_id folderMap.set('', currentFolderId);
folderMap.set('', currentFolderId); // root = current folder
// Collect all unique folder paths
const folderPaths = new Set(); const folderPaths = new Set();
for (const file of files) { for (const file of files) {
const parts = file.webkitRelativePath.split('/'); const parts = file.webkitRelativePath.split('/');
// Remove filename, keep folder parts
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const path = parts.slice(0, i).join('/'); const path = parts.slice(0, i).join('/');
folderPaths.add(path); folderPaths.add(path);
} }
} }
// Sort paths by depth so parents are created first
const sortedPaths = [...folderPaths].sort((a, b) => const sortedPaths = [...folderPaths].sort((a, b) =>
a.split('/').length - b.split('/').length a.split('/').length - b.split('/').length
); );
// Create folders // Create folders first (no progress toast for folder creation)
for (const folderPath of sortedPaths) { for (const folderPath of sortedPaths) {
const parts = folderPath.split('/'); const parts = folderPath.split('/');
const folderName = parts[parts.length - 1]; const folderName = parts[parts.length - 1];
@@ -188,9 +297,12 @@ const fileOps = {
} }
} }
// Upload files into their respective folders // Upload files with progress toast
let uploadedCount = 0;
const totalFiles = files.length; const totalFiles = files.length;
this._initUploadToast(totalFiles);
let uploadedCount = 0;
let successCount = 0;
for (let i = 0; i < totalFiles; i++) { for (let i = 0; i < totalFiles; i++) {
const file = files[i]; const file = files[i];
@@ -202,33 +314,27 @@ const fileOps = {
formData.append('folder_id', targetFolderId); formData.append('folder_id', targetFolderId);
formData.append('file', file); formData.append('file', file);
try { const displayName = file.webkitRelativePath || file.name;
const response = await fetch('/api/files/upload', { const rowEls = this._addToastFileRow(displayName);
method: 'POST',
body: formData, const result = await this._uploadFileXHR(formData, rowEls);
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
uploadedCount++; uploadedCount++;
const percentComplete = (uploadedCount / totalFiles) * 100; if (progressBar) {
progressBar.style.width = percentComplete + '%'; progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
}
this._updateOverallProgress(uploadedCount, totalFiles);
if (response.ok) { if (result.ok) {
successCount++;
console.log(`Uploaded: ${file.webkitRelativePath}`); console.log(`Uploaded: ${file.webkitRelativePath}`);
} else { } else {
console.error(`Error uploading ${file.webkitRelativePath}:`, await response.text()); console.error(`Error uploading ${file.webkitRelativePath}`);
}
} catch (error) {
console.error(`Network error uploading ${file.webkitRelativePath}:`, error);
} }
} }
// Finish up // Finish
window.ui.showNotification('Folder uploaded', `${uploadedCount} files uploaded successfully`); this._finishUploadToast(successCount, totalFiles);
await new Promise(resolve => setTimeout(resolve, 800)); await new Promise(resolve => setTimeout(resolve, 800));
@@ -241,7 +347,7 @@ const fileOps = {
setTimeout(() => { setTimeout(() => {
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
if (dropzone) dropzone.style.display = 'none'; if (dropzone) dropzone.style.display = 'none';
uploadProgressDiv.style.display = 'none'; if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
}, 500); }, 500);
}, },
+2
View File
@@ -16,6 +16,8 @@
"upload": "Hochladen", "upload": "Hochladen",
"upload_files": "Dateien hochladen", "upload_files": "Dateien hochladen",
"upload_folder": "Ordner hochladen", "upload_folder": "Ordner hochladen",
"upload.uploading": "Wird hochgeladen...",
"upload.complete": "{count} / {total} hochgeladen",
"rename": "Umbenennen", "rename": "Umbenennen",
"move": "Verschieben nach...", "move": "Verschieben nach...",
"move_to": "Verschieben nach", "move_to": "Verschieben nach",
+2
View File
@@ -16,6 +16,8 @@
"upload": "Upload", "upload": "Upload",
"upload_files": "Upload files", "upload_files": "Upload files",
"upload_folder": "Upload folder", "upload_folder": "Upload folder",
"upload.uploading": "Uploading...",
"upload.complete": "{count} / {total} uploaded",
"rename": "Rename", "rename": "Rename",
"move": "Move to...", "move": "Move to...",
"move_to": "Move to", "move_to": "Move to",
+2
View File
@@ -135,6 +135,8 @@
"upload": "Subir", "upload": "Subir",
"upload_files": "Subir archivos", "upload_files": "Subir archivos",
"upload_folder": "Subir carpeta", "upload_folder": "Subir carpeta",
"upload.uploading": "Subiendo...",
"upload.complete": "{count} / {total} subidos",
"rename": "Renombrar", "rename": "Renombrar",
"move": "Mover a...", "move": "Mover a...",
"move_to": "Mover a", "move_to": "Mover a",
+2
View File
@@ -16,6 +16,8 @@
"upload": "بارگذاری", "upload": "بارگذاری",
"upload_files": "بارگذاری پرونده‌ها", "upload_files": "بارگذاری پرونده‌ها",
"upload_folder": "بارگذاری پوشه", "upload_folder": "بارگذاری پوشه",
"upload.uploading": "...در حال بارگذاری",
"upload.complete": "{count} / {total} بارگذاری شد",
"rename": "تغییر نام", "rename": "تغییر نام",
"move": "انتقال به...", "move": "انتقال به...",
"move_to": "انتقال به", "move_to": "انتقال به",
+2
View File
@@ -16,6 +16,8 @@
"upload": "Téléverser", "upload": "Téléverser",
"upload_files": "Téléverser des fichiers", "upload_files": "Téléverser des fichiers",
"upload_folder": "Téléverser un dossier", "upload_folder": "Téléverser un dossier",
"upload.uploading": "Envoi en cours...",
"upload.complete": "{count} / {total} envoyés",
"rename": "Renommer", "rename": "Renommer",
"move": "Déplacer vers...", "move": "Déplacer vers...",
"move_to": "Déplacer vers", "move_to": "Déplacer vers",
+3 -1
View File
@@ -16,6 +16,8 @@
"upload": "Carica", "upload": "Carica",
"upload_files": "Carica file", "upload_files": "Carica file",
"upload_folder": "Carica cartella", "upload_folder": "Carica cartella",
"upload.uploading": "Caricamento...",
"upload.complete": "{count} / {total} caricati",
"rename": "Rinomina", "rename": "Rinomina",
"move": "Sposta in...", "move": "Sposta in...",
"move_to": "Sposta in", "move_to": "Sposta in",
@@ -311,7 +313,7 @@
"fa": "Persiano", "fa": "Persiano",
"fr": "Francese", "fr": "Francese",
"de": "Tedesco", "de": "Tedesco",
"pt": "Portoghese", "pt": "Portoghese"
"it": "Italiano" "it": "Italiano"
} }
}, },
+2
View File
@@ -16,6 +16,8 @@
"upload": "Enviar", "upload": "Enviar",
"upload_files": "Enviar arquivos", "upload_files": "Enviar arquivos",
"upload_folder": "Enviar pasta", "upload_folder": "Enviar pasta",
"upload.uploading": "Enviando...",
"upload.complete": "{count} / {total} enviados",
"rename": "Renomear", "rename": "Renomear",
"move": "Mover para...", "move": "Mover para...",
"move_to": "Mover para", "move_to": "Mover para",
+2
View File
@@ -16,6 +16,8 @@
"upload": "上传", "upload": "上传",
"upload_files": "上传文件", "upload_files": "上传文件",
"upload_folder": "上传文件夹", "upload_folder": "上传文件夹",
"upload.uploading": "上传中...",
"upload.complete": "{count} / {total} 已上传",
"rename": "重命名", "rename": "重命名",
"move": "移动到...", "move": "移动到...",
"move_to": "移动到", "move_to": "移动到",
+1 -1
View File
@@ -1,5 +1,5 @@
// OxiCloud Service Worker // OxiCloud Service Worker
const CACHE_NAME = 'oxicloud-cache-v2'; const CACHE_NAME = 'oxicloud-cache-v3';
const ASSETS_TO_CACHE = [ const ASSETS_TO_CACHE = [
'/', '/',
'/index.html', '/index.html',