diff --git a/static/css/style.css b/static/css/style.css index 6a0a1fde..0753abad 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; diff --git a/static/index.html b/static/index.html index 389785a9..fe5430d2 100644 --- a/static/index.html +++ b/static/index.html @@ -19,6 +19,7 @@ + @@ -105,6 +106,29 @@
+ + +
+ +
+
+ Notifications + +
+
+
+ + No notifications +
+
+
+
+
- -
-
- Uploading... - -
-
- -
- -
+ + diff --git a/static/js/app.js b/static/js/app.js index 0876e453..f6daebd5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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 diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 160979f5..1a0c0ecb 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -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 = ` - -
-
${fileName}
-
-
- 0% - `; - 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 = ''; - 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 = ''; - 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 = ''; - 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; } } diff --git a/static/js/notifications.js b/static/js/notifications.js new file mode 100644 index 00000000..d3e2377e --- /dev/null +++ b/static/js/notifications.js @@ -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 = ` +
+
+
${_esc(title)}
+
${_esc(text)}
+
${_timeAgo()}
+
+ `; + // 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 = ` +
+
+
${_esc(uploadingText)}
+
+
+
+
+ 0% + 0 / ${totalFiles} +
+
+
${_timeAgo()}
+
+ `; + + // 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 = ` + + ${_esc(fileName)} + 0% + `; + 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 = ''; + pctEl.textContent = '100%'; + } else if (status === 'error') { + iconEl.innerHTML = ''; + 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 = ''; + } else { + iconEl.className = 'notif-item-icon error'; + iconEl.innerHTML = ''; + } + } + + // 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; diff --git a/static/locales/en.json b/static/locales/en.json index 584549bf..80de65e0 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -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" } } \ No newline at end of file diff --git a/static/locales/es.json b/static/locales/es.json index 0de91c0b..59d0c4d4 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -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" } } \ No newline at end of file diff --git a/static/sw.js b/static/sw.js index 5524761a..59cfc859 100644 --- a/static/sw.js +++ b/static/sw.js @@ -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',