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
This commit is contained in:
+281
-1
@@ -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 {
|
.upload-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -4175,6 +4410,51 @@ html[dir='rtl'] .fa-sign-out-alt {
|
|||||||
border-top-color: #334155;
|
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 */
|
/* Dropzone */
|
||||||
[data-theme="dark"] .dropzone {
|
[data-theme="dark"] .dropzone {
|
||||||
border-color: #475569;
|
border-color: #475569;
|
||||||
|
|||||||
+26
-16
@@ -19,6 +19,7 @@
|
|||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/js/i18n.js"></script>
|
<script src="/js/i18n.js"></script>
|
||||||
<script src="/js/languageSelector.js"></script>
|
<script src="/js/languageSelector.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
<script src="/js/modal.js"></script>
|
<script src="/js/modal.js"></script>
|
||||||
<script src="/js/ui.js"></script>
|
<script src="/js/ui.js"></script>
|
||||||
<script src="/js/contextMenus.js"></script>
|
<script src="/js/contextMenus.js"></script>
|
||||||
@@ -105,6 +106,29 @@
|
|||||||
|
|
||||||
<div class="user-controls">
|
<div class="user-controls">
|
||||||
<div id="language-selector"></div>
|
<div id="language-selector"></div>
|
||||||
|
|
||||||
|
<!-- Notification Bell -->
|
||||||
|
<div class="notif-wrapper" id="notif-wrapper">
|
||||||
|
<button class="notif-bell-btn" id="notif-bell-btn" title="Notifications">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span class="notif-badge" id="notif-badge" style="display:none">0</span>
|
||||||
|
</button>
|
||||||
|
<div class="notif-panel" id="notif-panel">
|
||||||
|
<div class="notif-panel-header">
|
||||||
|
<span class="notif-panel-title" data-i18n="notifications.title">Notifications</span>
|
||||||
|
<button class="notif-clear-btn" id="notif-clear-btn" title="Clear all">
|
||||||
|
<i class="fas fa-check-double"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-panel-body" id="notif-panel-body">
|
||||||
|
<div class="notif-empty" id="notif-empty">
|
||||||
|
<i class="fas fa-bell-slash"></i>
|
||||||
|
<span data-i18n="notifications.empty">No notifications</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="user-menu-wrapper" id="user-menu-wrapper">
|
<div class="user-menu-wrapper" id="user-menu-wrapper">
|
||||||
<button class="user-avatar-btn" id="user-avatar-btn">
|
<button class="user-avatar-btn" id="user-avatar-btn">
|
||||||
<div class="user-avatar" id="user-avatar">AD</div>
|
<div class="user-avatar" id="user-avatar">AD</div>
|
||||||
@@ -289,21 +313,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Progress Toast -->
|
<!-- Upload Progress Toast (hidden – driven by notification bell) -->
|
||||||
<div class="upload-toast" id="upload-toast">
|
<div class="upload-toast" id="upload-toast" style="display:none"></div>
|
||||||
<div class="upload-toast-header">
|
|
||||||
<span class="upload-toast-title" id="upload-toast-title">Uploading...</span>
|
|
||||||
<button class="upload-toast-close" id="upload-toast-close" title="Minimize">−</button>
|
|
||||||
</div>
|
|
||||||
<div class="upload-toast-body" id="upload-toast-body">
|
|
||||||
<!-- File entries will be added dynamically -->
|
|
||||||
</div>
|
|
||||||
<div class="upload-toast-footer">
|
|
||||||
<div class="upload-toast-overall-bar">
|
|
||||||
<div class="upload-toast-overall-fill" id="upload-toast-overall-fill"></div>
|
|
||||||
</div>
|
|
||||||
<span class="upload-toast-stats" id="upload-toast-stats"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ function setupUserMenu() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isOpen = wrapper.classList.contains('open');
|
const isOpen = wrapper.classList.contains('open');
|
||||||
wrapper.classList.toggle('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) {
|
if (!isOpen) {
|
||||||
updateUserMenuData();
|
updateUserMenuData();
|
||||||
// Show/hide admin panel button based on user role
|
// Show/hide admin panel button based on user role
|
||||||
|
|||||||
+48
-105
@@ -20,114 +20,49 @@ function getAuthHeaders() {
|
|||||||
const fileOps = {
|
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) {
|
_initUploadToast(totalFiles) {
|
||||||
const toast = document.getElementById('upload-toast');
|
this._currentBatchId = window.notifications
|
||||||
const body = document.getElementById('upload-toast-body');
|
? window.notifications.addUploadBatch(totalFiles)
|
||||||
const title = document.getElementById('upload-toast-title');
|
: null;
|
||||||
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');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Add a file row to the toast and return its element references */
|
/** Finalise the batch in the notification bell */
|
||||||
_addToastFileRow(fileName) {
|
|
||||||
const body = document.getElementById('upload-toast-body');
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'upload-toast-file';
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="upload-toast-file-icon"><i class="fas fa-spinner fa-spin"></i></span>
|
|
||||||
<div class="upload-toast-file-info">
|
|
||||||
<div class="upload-toast-file-name" title="${fileName}">${fileName}</div>
|
|
||||||
<div class="upload-toast-file-bar"><div class="upload-toast-file-fill"></div></div>
|
|
||||||
</div>
|
|
||||||
<span class="upload-toast-file-pct">0%</span>
|
|
||||||
`;
|
|
||||||
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 */
|
|
||||||
_finishUploadToast(successCount, totalFiles) {
|
_finishUploadToast(successCount, totalFiles) {
|
||||||
const title = document.getElementById('upload-toast-title');
|
if (window.notifications && this._currentBatchId) {
|
||||||
const fill = document.getElementById('upload-toast-overall-fill');
|
window.notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a single file via XMLHttpRequest with progress events.
|
* 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) => {
|
return new Promise((resolve) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
const notif = window.notifications;
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
if (e.lengthComputable && fileRowElements) {
|
if (e.lengthComputable && notif && batchId) {
|
||||||
const pct = Math.round((e.loaded / e.total) * 100);
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
fileRowElements.fill.style.width = pct + '%';
|
notif.updateFile(batchId, fileName, pct, 'uploading');
|
||||||
fileRowElements.pct.textContent = pct + '%';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
if (fileRowElements) {
|
if (notif && batchId) notif.updateFile(batchId, fileName, 100, 'done');
|
||||||
fileRowElements.fill.style.width = '100%';
|
|
||||||
fileRowElements.fill.classList.add('done');
|
|
||||||
fileRowElements.pct.textContent = '100%';
|
|
||||||
fileRowElements.icon.innerHTML = '<i class="fas fa-check-circle"></i>';
|
|
||||||
fileRowElements.icon.classList.add('done');
|
|
||||||
}
|
|
||||||
let data = null;
|
let data = null;
|
||||||
try { data = JSON.parse(xhr.responseText); } catch (_) {}
|
try { data = JSON.parse(xhr.responseText); } catch (_) {}
|
||||||
resolve({ ok: true, data });
|
resolve({ ok: true, data });
|
||||||
} else {
|
} else {
|
||||||
if (fileRowElements) {
|
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
|
||||||
fileRowElements.fill.classList.add('error');
|
|
||||||
fileRowElements.pct.textContent = 'ERR';
|
|
||||||
fileRowElements.icon.innerHTML = '<i class="fas fa-exclamation-circle"></i>';
|
|
||||||
fileRowElements.icon.classList.add('error');
|
|
||||||
}
|
|
||||||
// Parse error body for quota-exceeded or other messages
|
// Parse error body for quota-exceeded or other messages
|
||||||
let errorMsg = null;
|
let errorMsg = null;
|
||||||
let isQuotaError = false;
|
let isQuotaError = false;
|
||||||
@@ -141,12 +76,7 @@ const fileOps = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
if (fileRowElements) {
|
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
|
||||||
fileRowElements.fill.classList.add('error');
|
|
||||||
fileRowElements.pct.textContent = 'ERR';
|
|
||||||
fileRowElements.icon.innerHTML = '<i class="fas fa-exclamation-circle"></i>';
|
|
||||||
fileRowElements.icon.classList.add('error');
|
|
||||||
}
|
|
||||||
resolve({ ok: false });
|
resolve({ ok: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,8 +109,9 @@ const fileOps = {
|
|||||||
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
|
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
|
||||||
if (progressBar) { progressBar.style.width = '0%'; }
|
if (progressBar) { progressBar.style.width = '0%'; }
|
||||||
|
|
||||||
// Show upload toast
|
// Show upload notification
|
||||||
this._initUploadToast(totalFiles);
|
this._initUploadToast(totalFiles);
|
||||||
|
const batchId = this._currentBatchId;
|
||||||
|
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@@ -197,10 +128,7 @@ const fileOps = {
|
|||||||
file: file.name, size: file.size
|
file: file.name, size: file.size
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add row to toast
|
const result = await this._uploadFileXHR(formData, batchId, file.name);
|
||||||
const rowEls = this._addToastFileRow(file.name);
|
|
||||||
|
|
||||||
const result = await this._uploadFileXHR(formData, rowEls);
|
|
||||||
|
|
||||||
uploadedCount++;
|
uploadedCount++;
|
||||||
|
|
||||||
@@ -208,8 +136,10 @@ const fileOps = {
|
|||||||
if (progressBar) {
|
if (progressBar) {
|
||||||
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||||
}
|
}
|
||||||
// Toast overall bar
|
// Notify bell of per-file completion
|
||||||
this._updateOverallProgress(uploadedCount, totalFiles);
|
if (window.notifications && batchId) {
|
||||||
|
window.notifications.fileCompleted(batchId, result.ok);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -218,11 +148,15 @@ const fileOps = {
|
|||||||
console.error(`Upload error for ${file.name}`);
|
console.error(`Upload error for ${file.name}`);
|
||||||
if (result.isQuotaError) {
|
if (result.isQuotaError) {
|
||||||
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
|
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
|
||||||
window.ui.showNotification('Error', `${file.name}: ${msg}`);
|
if (window.notifications) {
|
||||||
// Stop uploading remaining files — quota is full
|
window.notifications.addNotification({
|
||||||
|
icon: 'fa-exclamation-triangle',
|
||||||
|
iconClass: 'error',
|
||||||
|
title: file.name,
|
||||||
|
text: msg
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
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;
|
const totalFiles = files.length;
|
||||||
this._initUploadToast(totalFiles);
|
this._initUploadToast(totalFiles);
|
||||||
|
const batchId = this._currentBatchId;
|
||||||
|
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@@ -335,15 +270,16 @@ const fileOps = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const displayName = file.webkitRelativePath || file.name;
|
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++;
|
uploadedCount++;
|
||||||
if (progressBar) {
|
if (progressBar) {
|
||||||
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||||
}
|
}
|
||||||
this._updateOverallProgress(uploadedCount, totalFiles);
|
if (window.notifications && batchId) {
|
||||||
|
window.notifications.fileCompleted(batchId, result.ok);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -352,7 +288,14 @@ const fileOps = {
|
|||||||
console.error(`Error uploading ${file.webkitRelativePath}`);
|
console.error(`Error uploading ${file.webkitRelativePath}`);
|
||||||
if (result.isQuotaError) {
|
if (result.isQuotaError) {
|
||||||
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<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;
|
||||||
@@ -338,6 +338,8 @@
|
|||||||
"file_deleted": "File moved to trash",
|
"file_deleted": "File moved to trash",
|
||||||
"folder_deleted": "Folder moved to trash",
|
"folder_deleted": "Folder moved to trash",
|
||||||
"item_deleted_permanently": "Item permanently deleted",
|
"item_deleted_permanently": "Item permanently deleted",
|
||||||
"trash_emptied": "Trash emptied successfully"
|
"trash_emptied": "Trash emptied successfully",
|
||||||
|
"title": "Notifications",
|
||||||
|
"empty": "No notifications"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +338,8 @@
|
|||||||
"file_deleted": "Archivo movido a papelera",
|
"file_deleted": "Archivo movido a papelera",
|
||||||
"folder_deleted": "Carpeta movida a papelera",
|
"folder_deleted": "Carpeta movida a papelera",
|
||||||
"item_deleted_permanently": "Elemento eliminado permanentemente",
|
"item_deleted_permanently": "Elemento eliminado permanentemente",
|
||||||
"trash_emptied": "Papelera vaciada correctamente"
|
"trash_emptied": "Papelera vaciada correctamente",
|
||||||
|
"title": "Notificaciones",
|
||||||
|
"empty": "Sin notificaciones"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,10 +1,11 @@
|
|||||||
// OxiCloud Service Worker
|
// OxiCloud Service Worker
|
||||||
const CACHE_NAME = 'oxicloud-cache-v9';
|
const CACHE_NAME = 'oxicloud-cache-v10';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/js/i18n.js',
|
'/js/i18n.js',
|
||||||
'/js/languageSelector.js',
|
'/js/languageSelector.js',
|
||||||
|
'/js/notifications.js',
|
||||||
'/locales/en.json',
|
'/locales/en.json',
|
||||||
'/locales/es.json',
|
'/locales/es.json',
|
||||||
'/locales/fa.json',
|
'/locales/fa.json',
|
||||||
|
|||||||
Reference in New Issue
Block a user