2026-04-13 15:09:10 +02:00
|
|
|
|
import { i18n } from './i18n.js';
|
|
|
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-04-13 15:09:10 +02:00
|
|
|
|
* Public API (exported as `notifications`):
|
2026-04-07 22:48:59 +02:00
|
|
|
|
* addUploadBatch(totalFiles) → batchId
|
|
|
|
|
|
* updateFile(batchId, fileName, pct, status)
|
|
|
|
|
|
* finishBatch(batchId, successCount, totalFiles)
|
|
|
|
|
|
* addNotification({ icon, iconClass, title, text })
|
|
|
|
|
|
* clear()
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @typedef {Object} BatchNotification
|
|
|
|
|
|
* @property {HTMLElement} el
|
|
|
|
|
|
* @property {Number} totalFiles,
|
|
|
|
|
|
* @property {Number} completed
|
|
|
|
|
|
* @property {Number} successCount
|
|
|
|
|
|
* @property {Number} errorCount
|
|
|
|
|
|
* @property {Number} lastLabelUpdateTs
|
|
|
|
|
|
* @property {String} lastLabelFile
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
|
const notifications = (() => {
|
|
|
|
|
|
/* ── state ──────────────────────────────────────────────── */
|
|
|
|
|
|
let _badgeCount = 0;
|
|
|
|
|
|
let _batchSeq = 0;
|
2026-05-07 23:40:02 +02:00
|
|
|
|
|
|
|
|
|
|
/** @type {Record<String,BatchNotification>} */
|
|
|
|
|
|
const _batches = {};
|
2026-04-07 22:48:59 +02:00
|
|
|
|
|
|
|
|
|
|
/* ── DOM refs (resolved lazily) ─────────────────────────── */
|
2026-05-07 23:40:02 +02:00
|
|
|
|
|
|
|
|
|
|
/** @type {(id: string) => HTMLElement | null } */
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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();
|
2026-04-30 02:02:27 +02:00
|
|
|
|
const open = wrapper?.classList.toggle('open');
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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) => {
|
2026-04-30 02:02:27 +02:00
|
|
|
|
if (!wrapper?.contains(/** @type {Node | null} */ (e.target))) {
|
2026-04-07 22:48:59 +02:00
|
|
|
|
close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Clear all
|
|
|
|
|
|
if (clearBtn) {
|
|
|
|
|
|
clearBtn.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
clear();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
const bellBtn = $('notif-bell-btn');
|
|
|
|
|
|
const wrapper = $('notif-wrapper');
|
2026-04-30 02:02:27 +02:00
|
|
|
|
wrapper?.classList.remove('open');
|
|
|
|
|
|
bellBtn?.classList.remove('active');
|
2026-04-07 22:48:59 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 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');
|
2026-04-30 02:02:27 +02:00
|
|
|
|
badge.textContent = _badgeCount > 99 ? '99+' : String(_badgeCount);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
} 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');
|
2026-04-07 22:50:42 +02:00
|
|
|
|
if (!wrapper?.classList.contains('open')) {
|
2026-04-07 22:48:59 +02:00
|
|
|
|
_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) {
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const batchId = `batch-${++_batchSeq}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
|
const body = $('notif-panel-body');
|
|
|
|
|
|
if (!body) return batchId;
|
|
|
|
|
|
|
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
|
item.className = 'notif-item';
|
|
|
|
|
|
item.id = batchId;
|
|
|
|
|
|
|
2026-04-25 23:19:36 +02:00
|
|
|
|
const uploadingText = folderName ? `📁 ${i18n.t('upload.uploading')} ${_esc(folderName)}…` : i18n.t('upload.uploading');
|
|
|
|
|
|
const filesLabel = i18n.t('upload.files');
|
2026-04-07 22:48:59 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const curEl = $(`${batchId}-current`);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
if (curEl) {
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const shortName = fileName.length > 50 ? `…${fileName.slice(-49)}` : fileName;
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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);
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const fillEl = $(`${batchId}-fill`);
|
|
|
|
|
|
const pctEl = $(`${batchId}-pct`);
|
|
|
|
|
|
if (fillEl) fillEl.style.width = `${overallPct}%`;
|
|
|
|
|
|
if (pctEl) pctEl.textContent = `${overallPct}%`;
|
2026-04-07 22:48:59 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Mark a file as completed within a batch (updates overall bar).
|
|
|
|
|
|
* DOM updates are throttled to every 5 files to avoid reflow starvation.
|
2026-05-07 23:40:02 +02:00
|
|
|
|
* @param {string} batchId
|
|
|
|
|
|
* @param {boolean} success
|
2026-04-07 22:48:59 +02:00
|
|
|
|
*/
|
|
|
|
|
|
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);
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const fillEl = $(`${batchId}-fill`);
|
|
|
|
|
|
const pctEl = $(`${batchId}-pct`);
|
|
|
|
|
|
const statsEl = $(`${batchId}-stats`);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
|
2026-04-25 23:19:36 +02:00
|
|
|
|
const filesLabel = i18n.t('upload.files');
|
2026-04-07 22:48:59 +02:00
|
|
|
|
|
2026-04-07 22:50:42 +02:00
|
|
|
|
if (fillEl) fillEl.style.width = `${pctVal}%`;
|
|
|
|
|
|
if (pctEl) pctEl.textContent = `${pctVal}%`;
|
2026-04-07 22:48:59 +02:00
|
|
|
|
if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles} ${filesLabel}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Finalise a batch – update icon and title.
|
2026-05-07 23:40:02 +02:00
|
|
|
|
* @param {string} batchId
|
|
|
|
|
|
* @param {number} successCount
|
|
|
|
|
|
* @param {number} totalFiles
|
2026-04-07 22:48:59 +02:00
|
|
|
|
*/
|
|
|
|
|
|
function finishBatch(batchId, successCount, totalFiles) {
|
|
|
|
|
|
const batch = _batches[batchId];
|
|
|
|
|
|
if (!batch) return;
|
|
|
|
|
|
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const fillEl = $(`${batchId}-fill`);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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
|
2026-04-07 22:50:42 +02:00
|
|
|
|
const curEl = $(`${batchId}-current`);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
if (curEl) curEl.textContent = '';
|
|
|
|
|
|
|
2026-04-25 23:19:36 +02:00
|
|
|
|
const completeText = i18n.t('upload.complete', {
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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');
|
2026-04-07 22:50:42 +02:00
|
|
|
|
if (!wrapper?.classList.contains('open')) {
|
2026-04-07 22:48:59 +02:00
|
|
|
|
_incrementBadge();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── clear all ──────────────────────────────────────────── */
|
|
|
|
|
|
function clear() {
|
|
|
|
|
|
const body = $('notif-panel-body');
|
|
|
|
|
|
if (!body) return;
|
|
|
|
|
|
// Remove all notif-items
|
2026-04-07 22:50:42 +02:00
|
|
|
|
body.querySelectorAll('.notif-item').forEach((el) => {
|
|
|
|
|
|
el.remove();
|
|
|
|
|
|
});
|
2026-04-07 22:48:59 +02:00
|
|
|
|
_clearBadge();
|
|
|
|
|
|
_showEmptyIfNeeded();
|
|
|
|
|
|
// automatically close notification center on clear
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
close();
|
|
|
|
|
|
}, 600);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── util ───────────────────────────────────────────────── */
|
2026-05-07 23:40:02 +02:00
|
|
|
|
// FIXME move to global library
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {string} s
|
|
|
|
|
|
*/
|
2026-04-07 22:48:59 +02:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-04-13 15:09:10 +02:00
|
|
|
|
export { notifications };
|