Files
Oxicloud/static/js/core/notifications.js
T
Diocrafts a1a3bd1b2b fix: folder trash/delete operations & frontend refactoring
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' does not exist)
- Fix folder deletion: delete descendant files before folder to avoid 'duplicate key violates unique constraint idx_files_unique_name_at_root'
- Simplify trash model: only mark the folder as trashed, not child files (implicit trash via parent)
- Update trash_items view: filter to show only top-level trashed items
- Update schema.sql: change files.folder_id FK from ON DELETE SET NULL to ON DELETE CASCADE
- Fix trash view icons: folders and files now show correct visual icons (folder-icon, pdf-icon, etc.) in trash view
- Frontend refactoring: extract inline CSS/JS from admin.html and profile.html into dedicated external files
- Frontend cleanup: replace all inline style attributes with CSS classes
- Frontend cleanup: replace style.display JS
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' d
2026-02-20 12:27:52 +01:00

338 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.
/**
* 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.
* 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 locale = window.i18n?.getCurrentLocale?.() || 'en';
const uploadingText = folderName
? (locale.startsWith('es') ? `📁 Subiendo ${_esc(folderName)}…` : `📁 Uploading ${_esc(folderName)}…`)
: (locale.startsWith('es') ? 'Subiendo…' : 'Uploading…');
const filesLabel = locale.startsWith('es') ? 'archivos' : '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" style="font-size:11px;color:#64748b;margin:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></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;
// Only update the current-file label (single DOM element)
const curEl = $(batchId + '-current');
if (curEl && status === 'uploading') {
const now = Date.now();
const fileChanged = batch.lastLabelFile !== fileName;
const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100;
if (!shouldUpdate) return;
// Show just the file name being uploaded (truncate long paths)
const shortName = fileName.length > 50
? '…' + fileName.slice(-49)
: fileName;
curEl.textContent = shortName;
batch.lastLabelFile = fileName;
batch.lastLabelUpdateTs = now;
}
}
/**
* 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 locale = window.i18n?.getCurrentLocale?.() || 'en';
const filesLabel = locale.startsWith('es') ? 'archivos' : '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 locale = window.i18n?.getCurrentLocale?.() || 'en';
const filesLabel = locale.startsWith('es') ? 'archivos' : 'files';
const completeText = locale.startsWith('es')
? `✅ ${successCount} / ${totalFiles} ${filesLabel} subidos`
: `✅ ${successCount} / ${totalFiles} ${filesLabel} 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;