feat(#93): notification bell with upload progress

Replace the floating upload toast with a notification bell in the top bar
(between language selector and user avatar). All upload progress, completion,
and quota errors now flow through the bell dropdown panel.

- Add notification bell button with animated badge counter
- Dropdown panel shows per-file upload progress bars and overall batch progress
- Bell rings on new notifications when panel is closed
- Upload success/error states with color-coded icons
- Quota exceeded errors shown as notification items
- Clear all button to dismiss notifications
- Panel auto-opens when upload starts
- Full dark mode support
- Mutual exclusion with user menu (opening one closes the other)
- i18n keys for en/es (notifications.title, notifications.empty)
- SW cache bump to v10

Files:
- static/js/notifications.js (new module)
- static/index.html: bell markup + remove old toast
- static/css/style.css: bell + panel styles + dark mode
- static/js/fileOperations.js: redirect upload progress to notification bell
- static/js/app.js: close bell when user menu opens
- static/locales/{en,es}.json: i18n keys
- static/sw.js: cache v10 + notifications.js asset
This commit is contained in:
Dionisio
2026-02-14 10:46:23 +01:00
parent 3f60765d9a
commit 1672889044
8 changed files with 688 additions and 125 deletions
+48 -105
View File
@@ -20,114 +20,49 @@ function getAuthHeaders() {
const fileOps = {
// ========================================================================
// Upload progress toast helpers
// Upload progress — notification bell integration
// ========================================================================
_currentBatchId: null,
/** Show the upload progress toast and reset its contents */
/** Start a new upload batch in the notification bell */
_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');
this._currentBatchId = window.notifications
? window.notifications.addUploadBatch(totalFiles)
: null;
},
/** 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 */
/** Finalise the batch in the notification bell */
_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);
if (window.notifications && this._currentBatchId) {
window.notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
}
},
/**
* Upload a single file via XMLHttpRequest with progress events.
* Returns a promise that resolves with { ok, data? }.
* Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
*/
_uploadFileXHR(formData, fileRowElements) {
_uploadFileXHR(formData, batchId, fileName) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
const notif = window.notifications;
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && fileRowElements) {
if (e.lengthComputable && notif && batchId) {
const pct = Math.round((e.loaded / e.total) * 100);
fileRowElements.fill.style.width = pct + '%';
fileRowElements.pct.textContent = pct + '%';
notif.updateFile(batchId, fileName, pct, 'uploading');
}
});
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');
}
if (notif && batchId) notif.updateFile(batchId, fileName, 100, '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');
}
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
// Parse error body for quota-exceeded or other messages
let errorMsg = null;
let isQuotaError = false;
@@ -141,12 +76,7 @@ const fileOps = {
});
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');
}
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
resolve({ ok: false });
});
@@ -179,8 +109,9 @@ const fileOps = {
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
if (progressBar) { progressBar.style.width = '0%'; }
// Show upload toast
// Show upload notification
this._initUploadToast(totalFiles);
const batchId = this._currentBatchId;
let uploadedCount = 0;
let successCount = 0;
@@ -197,10 +128,7 @@ const fileOps = {
file: file.name, size: file.size
});
// Add row to toast
const rowEls = this._addToastFileRow(file.name);
const result = await this._uploadFileXHR(formData, rowEls);
const result = await this._uploadFileXHR(formData, batchId, file.name);
uploadedCount++;
@@ -208,8 +136,10 @@ const fileOps = {
if (progressBar) {
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
}
// Toast overall bar
this._updateOverallProgress(uploadedCount, totalFiles);
// Notify bell of per-file completion
if (window.notifications && batchId) {
window.notifications.fileCompleted(batchId, result.ok);
}
if (result.ok) {
successCount++;
@@ -218,11 +148,15 @@ const fileOps = {
console.error(`Upload error for ${file.name}`);
if (result.isQuotaError) {
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
window.ui.showNotification('Error', `${file.name}: ${msg}`);
// Stop uploading remaining files — quota is full
if (window.notifications) {
window.notifications.addNotification({
icon: 'fa-exclamation-triangle',
iconClass: 'error',
title: file.name,
text: msg
});
}
break;
} else {
window.ui.showNotification('Error', `Error uploading file: ${file.name}`);
}
}
}
@@ -317,9 +251,10 @@ const fileOps = {
}
}
// Upload files with progress toast
// Upload files with notification bell
const totalFiles = files.length;
this._initUploadToast(totalFiles);
const batchId = this._currentBatchId;
let uploadedCount = 0;
let successCount = 0;
@@ -335,15 +270,16 @@ const fileOps = {
formData.append('file', file);
const displayName = file.webkitRelativePath || file.name;
const rowEls = this._addToastFileRow(displayName);
const result = await this._uploadFileXHR(formData, rowEls);
const result = await this._uploadFileXHR(formData, batchId, displayName);
uploadedCount++;
if (progressBar) {
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
}
this._updateOverallProgress(uploadedCount, totalFiles);
if (window.notifications && batchId) {
window.notifications.fileCompleted(batchId, result.ok);
}
if (result.ok) {
successCount++;
@@ -352,7 +288,14 @@ const fileOps = {
console.error(`Error uploading ${file.webkitRelativePath}`);
if (result.isQuotaError) {
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
window.ui.showNotification('Error', `${file.name}: ${msg}`);
if (window.notifications) {
window.notifications.addNotification({
icon: 'fa-exclamation-triangle',
iconClass: 'error',
title: file.name,
text: msg
});
}
break;
}
}