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
+281 -1
View File
@@ -3484,7 +3484,242 @@ html[dir='rtl'] .fa-sign-out-alt {
}
/* ============================================================================
Upload Progress Toast
Notification Bell
============================================================================ */
.notif-wrapper {
position: relative;
}
.notif-bell-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: #64748b;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
}
.notif-bell-btn:hover {
background: rgba(255, 94, 58, 0.08);
color: #ff5e3a;
}
.notif-bell-btn.active {
color: #ff5e3a;
background: rgba(255, 94, 58, 0.1);
}
/* Badge counter on bell */
.notif-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
line-height: 16px;
border-radius: 8px;
background: #ff3b30;
color: #fff;
font-size: 10px;
font-weight: 700;
text-align: center;
padding: 0 4px;
pointer-events: none;
}
/* Animated wiggle for new notifications */
@keyframes bellRing {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(14deg); }
30% { transform: rotate(-14deg); }
45% { transform: rotate(8deg); }
60% { transform: rotate(-8deg); }
75% { transform: rotate(3deg); }
}
.notif-bell-btn.ring i {
animation: bellRing 0.6s ease;
}
/* Dropdown panel */
.notif-panel {
display: none;
position: absolute;
top: calc(100% + 10px);
right: -40px;
width: 380px;
max-height: 480px;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 2000;
overflow: hidden;
animation: notifPanelIn 0.2s ease-out;
}
.notif-wrapper.open .notif-panel {
display: flex;
flex-direction: column;
}
@keyframes notifPanelIn {
from { opacity: 0; transform: translateY(-8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.notif-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f0f0f0;
}
.notif-panel-title {
font-weight: 600;
font-size: 15px;
color: #1e293b;
}
.notif-clear-btn {
background: none;
border: none;
cursor: pointer;
color: #94a3b8;
font-size: 14px;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.15s;
}
.notif-clear-btn:hover {
color: #ff5e3a;
background: rgba(255, 94, 58, 0.08);
}
.notif-panel-body {
flex: 1;
overflow-y: auto;
max-height: 400px;
}
.notif-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #94a3b8;
gap: 8px;
}
.notif-empty i {
font-size: 28px;
opacity: 0.5;
}
.notif-empty span {
font-size: 14px;
}
/* Individual notification items */
.notif-item {
display: flex;
align-items: flex-start;
padding: 12px 16px;
gap: 12px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
cursor: default;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item:hover {
background: #f8fafc;
}
.notif-item-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.notif-item-icon.upload {
background: rgba(255, 94, 58, 0.1);
color: #ff5e3a;
}
.notif-item-icon.success {
background: rgba(52, 199, 89, 0.1);
color: #34c759;
}
.notif-item-icon.error {
background: rgba(255, 59, 48, 0.1);
color: #ff3b30;
}
.notif-item-body {
flex: 1;
min-width: 0;
}
.notif-item-title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-bottom: 2px;
}
.notif-item-text {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-item-time {
font-size: 11px;
color: #94a3b8;
margin-top: 3px;
}
/* Upload progress inside notification */
.notif-upload-progress {
margin-top: 6px;
}
.notif-upload-bar {
height: 3px;
background: #eee;
border-radius: 2px;
overflow: hidden;
}
.notif-upload-fill {
height: 100%;
background: #ff5e3a;
width: 0%;
transition: width 0.2s ease;
border-radius: 2px;
}
.notif-upload-fill.done {
background: #34c759;
}
.notif-upload-fill.error {
background: #ff3b30;
}
.notif-upload-detail {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 3px;
}
.notif-upload-pct {
font-size: 11px;
color: #94a3b8;
}
.notif-upload-stats {
font-size: 11px;
color: #94a3b8;
}
/* ============================================================================
Upload Progress Toast (legacy — hidden, kept for compat)
============================================================================ */
.upload-toast {
position: fixed;
@@ -4175,6 +4410,51 @@ html[dir='rtl'] .fa-sign-out-alt {
border-top-color: #334155;
}
/* Notification bell */
[data-theme="dark"] .notif-bell-btn {
color: #94a3b8;
}
[data-theme="dark"] .notif-bell-btn:hover,
[data-theme="dark"] .notif-bell-btn.active {
color: #ff5e3a;
background: rgba(255, 94, 58, 0.15);
}
[data-theme="dark"] .notif-panel {
background: #1e293b;
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06);
}
[data-theme="dark"] .notif-panel-header {
border-bottom-color: #334155;
}
[data-theme="dark"] .notif-panel-title {
color: #f1f5f9;
}
[data-theme="dark"] .notif-clear-btn {
color: #64748b;
}
[data-theme="dark"] .notif-clear-btn:hover {
color: #ff5e3a;
background: rgba(255, 94, 58, 0.12);
}
[data-theme="dark"] .notif-empty {
color: #64748b;
}
[data-theme="dark"] .notif-item {
border-bottom-color: #1a2536;
}
[data-theme="dark"] .notif-item:hover {
background: #162032;
}
[data-theme="dark"] .notif-item-title {
color: #e2e8f0;
}
[data-theme="dark"] .notif-item-text {
color: #94a3b8;
}
[data-theme="dark"] .notif-upload-bar {
background: #334155;
}
/* Dropzone */
[data-theme="dark"] .dropzone {
border-color: #475569;
+26 -16
View File
@@ -19,6 +19,7 @@
<!-- Scripts -->
<script src="/js/i18n.js"></script>
<script src="/js/languageSelector.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/modal.js"></script>
<script src="/js/ui.js"></script>
<script src="/js/contextMenus.js"></script>
@@ -105,6 +106,29 @@
<div class="user-controls">
<div id="language-selector"></div>
<!-- Notification Bell -->
<div class="notif-wrapper" id="notif-wrapper">
<button class="notif-bell-btn" id="notif-bell-btn" title="Notifications">
<i class="fas fa-bell"></i>
<span class="notif-badge" id="notif-badge" style="display:none">0</span>
</button>
<div class="notif-panel" id="notif-panel">
<div class="notif-panel-header">
<span class="notif-panel-title" data-i18n="notifications.title">Notifications</span>
<button class="notif-clear-btn" id="notif-clear-btn" title="Clear all">
<i class="fas fa-check-double"></i>
</button>
</div>
<div class="notif-panel-body" id="notif-panel-body">
<div class="notif-empty" id="notif-empty">
<i class="fas fa-bell-slash"></i>
<span data-i18n="notifications.empty">No notifications</span>
</div>
</div>
</div>
</div>
<div class="user-menu-wrapper" id="user-menu-wrapper">
<button class="user-avatar-btn" id="user-avatar-btn">
<div class="user-avatar" id="user-avatar">AD</div>
@@ -289,21 +313,7 @@
</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>
<!-- Upload Progress Toast (hidden – driven by notification bell) -->
<div class="upload-toast" id="upload-toast" style="display:none"></div>
</body>
</html>
+7
View File
@@ -161,6 +161,13 @@ function setupUserMenu() {
e.stopPropagation();
const isOpen = wrapper.classList.contains('open');
wrapper.classList.toggle('open');
// Close notification bell if open
const notifWrapper = document.getElementById('notif-wrapper');
const notifBtn = document.getElementById('notif-bell-btn');
if (notifWrapper) notifWrapper.classList.remove('open');
if (notifBtn) notifBtn.classList.remove('active');
if (!isOpen) {
updateUserMenuData();
// Show/hide admin panel button based on user role
+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;
}
}
+318
View File
@@ -0,0 +1,318 @@
/**
* OxiCloud – Notification Bell Module
*
* Centralised notification system that renders items inside the bell dropdown
* in the top-bar. Upload progress, quota errors, and general messages all
* go through this module.
*
* Public API (on window.notifications):
* addUploadBatch(totalFiles) → batchId
* updateFile(batchId, fileName, pct, status)
* finishBatch(batchId, successCount, totalFiles)
* addNotification({ icon, iconClass, title, text })
* clear()
*/
const notifications = (() => {
'use strict';
/* ── state ──────────────────────────────────────────────── */
let _badgeCount = 0;
let _batchSeq = 0;
const _batches = {}; // batchId → { el, files:{}, totalFiles }
/* ── DOM refs (resolved lazily) ─────────────────────────── */
const $ = (id) => document.getElementById(id);
/* ── bell toggle ────────────────────────────────────────── */
function _initBell() {
const bellBtn = $('notif-bell-btn');
const wrapper = $('notif-wrapper');
const clearBtn = $('notif-clear-btn');
if (!bellBtn) return;
bellBtn.addEventListener('click', (e) => {
e.stopPropagation();
const open = wrapper.classList.toggle('open');
bellBtn.classList.toggle('active', open);
// Close user-menu if it's open
const um = $('user-menu-wrapper');
if (um) um.classList.remove('open');
if (open) _clearBadge();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
wrapper.classList.remove('open');
bellBtn.classList.remove('active');
}
});
// Clear all
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.stopPropagation();
clear();
});
}
}
/* ── badge helpers ──────────────────────────────────────── */
function _incrementBadge() {
_badgeCount++;
_renderBadge();
_ringBell();
}
function _clearBadge() {
_badgeCount = 0;
_renderBadge();
}
function _renderBadge() {
const badge = $('notif-badge');
if (!badge) return;
if (_badgeCount > 0) {
badge.style.display = '';
badge.textContent = _badgeCount > 99 ? '99+' : _badgeCount;
} else {
badge.style.display = 'none';
}
}
function _ringBell() {
const btn = $('notif-bell-btn');
if (!btn) return;
btn.classList.remove('ring');
// Force reflow so the animation restarts
void btn.offsetWidth;
btn.classList.add('ring');
}
/* ── empty state ────────────────────────────────────────── */
function _showEmptyIfNeeded() {
const body = $('notif-panel-body');
const empty = $('notif-empty');
if (!body || !empty) return;
// Any real items?
const hasItems = body.querySelector('.notif-item') !== null;
empty.style.display = hasItems ? 'none' : '';
}
/* ── generic notification ───────────────────────────────── */
function addNotification({ icon = 'fa-info-circle', iconClass = 'upload', title = '', text = '' }) {
const body = $('notif-panel-body');
if (!body) return;
const item = document.createElement('div');
item.className = 'notif-item';
item.innerHTML = `
<div class="notif-item-icon ${iconClass}"><i class="fas ${icon}"></i></div>
<div class="notif-item-body">
<div class="notif-item-title">${_esc(title)}</div>
<div class="notif-item-text" title="${_esc(text)}">${_esc(text)}</div>
<div class="notif-item-time">${_timeAgo()}</div>
</div>
`;
// Insert at top
body.insertBefore(item, body.firstChild);
// If panel is closed, bump badge
const wrapper = $('notif-wrapper');
if (!wrapper || !wrapper.classList.contains('open')) {
_incrementBadge();
}
_showEmptyIfNeeded();
}
/* ── upload batch API ───────────────────────────────────── */
/**
* Start tracking a new upload batch. Returns a batchId string.
* This also auto-opens the panel so users see progress.
*/
function addUploadBatch(totalFiles) {
const batchId = 'batch-' + (++_batchSeq);
const body = $('notif-panel-body');
if (!body) return batchId;
const item = document.createElement('div');
item.className = 'notif-item';
item.id = batchId;
const uploadingText = (window.i18n && window.i18n.t) ? window.i18n.t('upload.uploading') : 'Uploading…';
item.innerHTML = `
<div class="notif-item-icon upload"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="notif-item-body">
<div class="notif-item-title">${_esc(uploadingText)}</div>
<div class="notif-upload-files" id="${batchId}-files"></div>
<div class="notif-upload-progress">
<div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div>
<div class="notif-upload-detail">
<span class="notif-upload-pct" id="${batchId}-pct">0%</span>
<span class="notif-upload-stats" id="${batchId}-stats">0 / ${totalFiles}</span>
</div>
</div>
<div class="notif-item-time">${_timeAgo()}</div>
</div>
`;
// Insert at top
body.insertBefore(item, body.firstChild);
_batches[batchId] = { el: item, files: {}, totalFiles, completed: 0, successCount: 0 };
_showEmptyIfNeeded();
// Auto open
const wrapper = $('notif-wrapper');
const bellBtn = $('notif-bell-btn');
if (wrapper && !wrapper.classList.contains('open')) {
wrapper.classList.add('open');
if (bellBtn) bellBtn.classList.add('active');
}
return batchId;
}
/**
* Add / update a single file row inside a batch.
* @param {string} batchId
* @param {string} fileName
* @param {number} pct 0-100
* @param {'uploading'|'done'|'error'} status
*/
function updateFile(batchId, fileName, pct, status) {
const batch = _batches[batchId];
if (!batch) return;
const filesEl = $(batchId + '-files');
if (!filesEl) return;
let row = batch.files[fileName];
if (!row) {
row = document.createElement('div');
row.className = 'notif-upload-file-row';
row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;';
row.innerHTML = `
<span class="notif-file-icon" style="width:16px;text-align:center;color:#999;flex-shrink:0;"><i class="fas fa-spinner fa-spin"></i></span>
<span class="notif-file-name" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;" title="${_esc(fileName)}">${_esc(fileName)}</span>
<span class="notif-file-pct" style="width:34px;text-align:right;color:#94a3b8;flex-shrink:0;">0%</span>
`;
filesEl.appendChild(row);
batch.files[fileName] = row;
}
const iconEl = row.querySelector('.notif-file-icon');
const pctEl = row.querySelector('.notif-file-pct');
pctEl.textContent = pct + '%';
if (status === 'done') {
iconEl.innerHTML = '<i class="fas fa-check-circle" style="color:#34c759"></i>';
pctEl.textContent = '100%';
} else if (status === 'error') {
iconEl.innerHTML = '<i class="fas fa-exclamation-circle" style="color:#ff3b30"></i>';
pctEl.textContent = 'ERR';
}
}
/**
* Mark a file as completed within a batch (updates overall bar).
*/
function fileCompleted(batchId, success) {
const batch = _batches[batchId];
if (!batch) return;
batch.completed++;
if (success) batch.successCount++;
const pctVal = Math.round((batch.completed / batch.totalFiles) * 100);
const fillEl = $(batchId + '-fill');
const pctEl = $(batchId + '-pct');
const statsEl = $(batchId + '-stats');
if (fillEl) fillEl.style.width = pctVal + '%';
if (pctEl) pctEl.textContent = pctVal + '%';
if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles}`;
}
/**
* Finalise a batch – update icon and title.
*/
function finishBatch(batchId, successCount, totalFiles) {
const batch = _batches[batchId];
if (!batch) return;
const fillEl = $(batchId + '-fill');
if (fillEl) {
fillEl.style.width = '100%';
fillEl.classList.add(successCount === totalFiles ? 'done' : 'error');
}
const titleEl = batch.el.querySelector('.notif-item-title');
const iconEl = batch.el.querySelector('.notif-item-icon');
const completeText = (window.i18n && window.i18n.t)
? window.i18n.t('upload.complete', { count: successCount, total: totalFiles })
: `${successCount} / ${totalFiles} uploaded`;
if (titleEl) titleEl.textContent = completeText;
if (iconEl) {
if (successCount === totalFiles) {
iconEl.className = 'notif-item-icon success';
iconEl.innerHTML = '<i class="fas fa-check-circle"></i>';
} else {
iconEl.className = 'notif-item-icon error';
iconEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
}
}
// If the panel is closed, bump badge
const wrapper = $('notif-wrapper');
if (!wrapper || !wrapper.classList.contains('open')) {
_incrementBadge();
}
}
/* ── clear all ──────────────────────────────────────────── */
function clear() {
const body = $('notif-panel-body');
if (!body) return;
// Remove all notif-items
body.querySelectorAll('.notif-item').forEach(el => el.remove());
_clearBadge();
_showEmptyIfNeeded();
}
/* ── util ───────────────────────────────────────────────── */
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _timeAgo() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
/* ── init on DOM ready ──────────────────────────────────── */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _initBell);
} else {
_initBell();
}
/* ── public API ─────────────────────────────────────────── */
return {
addUploadBatch,
updateFile,
fileCompleted,
finishBatch,
addNotification,
clear,
};
})();
window.notifications = notifications;
+3 -1
View File
@@ -338,6 +338,8 @@
"file_deleted": "File moved to trash",
"folder_deleted": "Folder moved to trash",
"item_deleted_permanently": "Item permanently deleted",
"trash_emptied": "Trash emptied successfully"
"trash_emptied": "Trash emptied successfully",
"title": "Notifications",
"empty": "No notifications"
}
}
+3 -1
View File
@@ -338,6 +338,8 @@
"file_deleted": "Archivo movido a papelera",
"folder_deleted": "Carpeta movida a papelera",
"item_deleted_permanently": "Elemento eliminado permanentemente",
"trash_emptied": "Papelera vaciada correctamente"
"trash_emptied": "Papelera vaciada correctamente",
"title": "Notificaciones",
"empty": "Sin notificaciones"
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
// OxiCloud Service Worker
const CACHE_NAME = 'oxicloud-cache-v9';
const CACHE_NAME = 'oxicloud-cache-v10';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/js/i18n.js',
'/js/languageSelector.js',
'/js/notifications.js',
'/locales/en.json',
'/locales/es.json',
'/locales/fa.json',