Files
Oxicloud/static/js/notifications.js
T
Dionisio 1672889044 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
2026-02-14 10:46:23 +01:00

319 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;