Files
Oxicloud/static/js/core/notifications.js
T
Edouard Vanbelle 6f4abfcec4 refactor(js): avoid use of window.XXX and move to import/export
- change worker: do not cache html pages (not necessary)
- remove use of window.XXX and maximize import/export,
  this will provide more clarety,
  show circular dependencies + you will benefit IDE help
2026-04-14 19:05:01 +02:00

357 lines
13 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.
import { i18n } from './i18n.js';
/**
* 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 (exported as `notifications`):
* addUploadBatch(totalFiles) → batchId
* updateFile(batchId, fileName, pct, status)
* finishBatch(batchId, successCount, totalFiles)
* addNotification({ icon, iconClass, title, text })
* clear()
*/
const notifications = (() => {
/* ── 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)) {
close();
}
});
// Clear all
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.stopPropagation();
clear();
});
}
}
function close() {
const bellBtn = $('notif-bell-btn');
const wrapper = $('notif-wrapper');
wrapper.classList.remove('open');
bellBtn.classList.remove('active');
}
/* ── 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.classList.remove('hidden');
badge.textContent = _badgeCount > 99 ? '99+' : _badgeCount;
} else {
badge.classList.add('hidden');
}
}
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?.classList.contains('open')) {
_incrementBadge();
}
_showEmptyIfNeeded();
}
/* ── upload batch API ───────────────────────────────────── */
/**
* Start tracking a new upload batch. Returns a batchId string.
* Always uses compact folder-level display: one progress bar + counter.
* @param {number} totalFiles
* @param {string} [folderName] root folder name (for folder uploads)
*/
function addUploadBatch(totalFiles, folderName) {
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 t = i18n?.t || ((k) => k);
const uploadingText = folderName ? `📁 ${t('upload.uploading')} ${_esc(folderName)}…` : t('upload.uploading');
const filesLabel = t('upload.files');
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">${uploadingText}</div>
<div class="notif-upload-current" id="${batchId}-current"></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} ${filesLabel}</span>
</div>
</div>
<div class="notif-item-time">${_timeAgo()}</div>
</div>
`;
// Insert at top
body.insertBefore(item, body.firstChild);
_batches[batchId] = {
el: item,
totalFiles,
completed: 0,
successCount: 0,
errorCount: 0,
lastLabelUpdateTs: 0,
lastLabelFile: ''
};
_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;
}
/**
* Update the current-file label inside a batch.
* This does NOT create any DOM rows — just a single text update.
* @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;
if (status === 'error') batch.errorCount = (batch.errorCount || 0) + 1;
// Update the current-file label AND progress bar during upload
if (status === 'uploading') {
const now = Date.now();
const fileChanged = batch.lastLabelFile !== fileName;
// Throttle DOM updates to avoid reflow storms (every 300ms or on file change)
const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100;
if (!shouldUpdate) return;
// Show just the file name being uploaded (truncate long paths)
const curEl = $(`${batchId}-current`);
if (curEl) {
const shortName = fileName.length > 50 ? `…${fileName.slice(-49)}` : fileName;
curEl.textContent = shortName;
}
batch.lastLabelFile = fileName;
batch.lastLabelUpdateTs = now;
// Update progress bar with per-file granularity:
// overall% = (completed_files + current_file_fraction) / total_files
const overallPct = Math.round(((batch.completed + pct / 100) / batch.totalFiles) * 100);
const fillEl = $(`${batchId}-fill`);
const pctEl = $(`${batchId}-pct`);
if (fillEl) fillEl.style.width = `${overallPct}%`;
if (pctEl) pctEl.textContent = `${overallPct}%`;
}
}
/**
* Mark a file as completed within a batch (updates overall bar).
* DOM updates are throttled to every 5 files to avoid reflow starvation.
*/
function fileCompleted(batchId, success) {
const batch = _batches[batchId];
if (!batch) return;
batch.completed++;
if (success) batch.successCount++;
// Throttle DOM updates: every 5 files, or on the very last file
const isLast = batch.completed >= batch.totalFiles;
if (!isLast && batch.completed % 5 !== 0) return;
const pctVal = Math.round((batch.completed / batch.totalFiles) * 100);
const fillEl = $(`${batchId}-fill`);
const pctEl = $(`${batchId}-pct`);
const statsEl = $(`${batchId}-stats`);
const t = i18n?.t || ((k) => k);
const filesLabel = t('upload.files');
if (fillEl) fillEl.style.width = `${pctVal}%`;
if (pctEl) pctEl.textContent = `${pctVal}%`;
if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles} ${filesLabel}`;
}
/**
* 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');
// Clear the current-file label
const curEl = $(`${batchId}-current`);
if (curEl) curEl.textContent = '';
const t = i18n?.t || ((k) => k);
const completeText = t('upload.complete', {
count: successCount,
total: totalFiles
});
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?.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();
// automatically close notification center on clear
setTimeout(() => {
close();
}, 600);
}
/* ── 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
};
})();
export { notifications };