- Exception message and full stack trace: Added a lpx-menu-item on the siderbar, useing abp ajax call the sever data, then update the badge DOM with state and count, visual DOM generated but cannot update the DOM
- Steps to reproduce the issue: below is a code change plan :
用户需求
扩展project.Web 顶部工具栏 NotificationBadge 的提示铃铛,使其点击后展开一个通知下拉面板。
产品概述
将现有的“铃铛 + 数字徽章(轮询未读数)”改造为 LeptonX 风格的 lpx-context-menu 下拉通知中心:铃铛仅负责切换面板,面板内动态加载并展示最新的 4 条私信通知,按日期分组(TODAY / YESTERDAY / 具体日期),底部提供“See All Notifications”按钮跳转至完整的私信列表页 /notification/private-messages。
核心功能
- 铃铛图标保留未读数字徽章与现有 30 秒轮询逻辑(沿用
api/notification/private-message/unread-count)。 - 铃铛点击切换展开/收起 LeptonX 通知下拉面板(不再直接跳转)。
- 面板首次展开时通过
abp.ajax调用api/notification/private-message(MaxResultCount=4,Sorting=CreationTime DESC)动态拉取最新 4 条私信。 - 拉取到的条目按 CreationTime 在客户端换算为本地日期并分组为 TODAY / YESTERDAY / 具体日期区块渲染。
- 单条通知项仅作展示,不跳转、不触发标记已读;无头像数据时使用
bi-bell-fill成员图标样式,Title 作为高亮文本、Content 作为正文、日期/时间作为小字。 - 顶部显示“Notification [N] NEW”标题与关闭按钮,底部按钮链接至
/notification/private-messages。
技术栈
- 框架:ASP.NET Core + ABP Framework(LeptonX 主题),Razor 视图(
Default.cshtml) + 原生 JavaScript。 - 样式:复用 LeptonX 主题
lpx-*类名与 Bootstrap Icons(bi);不引入新依赖、不需前端构建。 - 数据交互:沿用现有
abp.ajax/abp.event模式,调用已存在接口。
实现方案
- 整体策略:仅重写
Default.cshtml的标记与脚本,将其升级为“铃铛触发 + 下拉面板”结构;视图组件NotificationBadgeViewComponent.cs无需改动(仍返回 Default.cshtml)。 - 关键决策:
- 铃铛
<a>改为data-lpx-ctx-toggle="notification-bar",移除原href跳转,仅切换下拉;跳转职责迁移到底部按钮(<a href="/notification/private-messages">或lpx-button包链接)。 - 下拉面板骨架沿用用户提供的结构:
lpx-context-menu+ 顶部标题/关闭(data-lpx-close)+ 分组容器(首次为空,由 JS 注入)+ 底部按钮。 - 数据条目无头像/发送人字段,采用模板中的“成员图标”样式(
lpx-notification-member-icon bi bi-bell-fill),以Title作为lpx-notification-text-highlight、Content作为正文。 - 未读高亮:DTO 无逐条已读标志,依据当前
unreadCount将前 N 条标记为unread(提供红色侧边/未读视觉),属合理近似;不触发后端标记已读。
- 性能与可靠性:列表仅 4 条、按日期分组在客户端 O(n) 完成,开销可忽略;首次展开懒加载,避免无谓请求;复用现有
window.__notificationPollTimer清理与abp.domUpdated重渲染清理逻辑,防止 SPA 下定时器/事件重复绑定与内存泄漏。CreationTime 为 UTC,分组与展示以new Date(item.creationTime)的本地日期判断 Today/Yesterday。
实现要点
- 保留并复用
menu-item-badge徽章样式与轮询;新增fetchLatest():组装 URL(abp.appPath||'') + 'api/notification/private-message?maxResultCount=4&sorting=CreationTime%20DESC',abp.ajaxGET,成功后将result.items渲染至面板。 - 日期分组函数:将每条
CreationTime转为本地Date,与“今天 00:00”“昨天 00:00”比较,归入TODAY/YESTERDAY/YYYY.MM.DD三类,按模板生成对应outer-menu-item+lpx-menu-item-title+lpx-notification-items区块。 - 时间文案:当天显示相对时间(如
1h ago)或HH:mm,非当天显示HH:mm;可在 JS 内简单格式化。 - 空态处理:若接口返回 0 条,渲染“暂无通知”占位,避免空白面板。
- 若
abp未就绪,沿用现有setInterval重试tryInit机制初始化下拉加载与轮询。
架构设计
- 不改变现有工具栏渲染链路:Toolbar → NotificationBadgeViewComponent → Default.cshtml(Razor 输出 HTML + 内联
<script>)。 - 新增逻辑全部内聚在该 cshtml 的脚本块内,沿用既有 IIFE 与全局清理约定,保持与现有代码一致,无新增模块或接口。
目录结构
apps/web/Project.Web/
└── Components/Toolbar/NotificationBadge/
└── Default.cshtml # [MODIFY] 重写标记:包装为 li#notification-bar + lpx-context-menu 下拉面板(标题/关闭、日期分组容器、底部跳转按钮);脚本增加 fetchLatest() 与日期分组渲染,并接入现有轮询/清理逻辑。
└── NotificationBadgeViewComponent.cs # [不变] 仅返回 Default.cshtml,无需修改。
设计风格
沿用 ABP LeptonX 主题的通知下拉(lpx-context-menu)视觉规范,保持与现有后台框架一致的现代、简洁、悬浮卡片风格。面板为右上角悬浮的白色/主题色圆角卡片,带轻微阴影与边框,与顶部工具栏铃铛联动展开/收起。
页面/区块设计(Default.cshtml 下拉面板)
- 顶部标题栏:左侧“Notification”文字 + 未读徽标
lpx-badge(如“2 NEW”),右侧关闭图标(data-lpx-close,点击收起面板),与铃铛徽章未读数联动。 - 分组通知区:按 TODAY / YESTERDAY / 具体日期分块,每块含
lpx-menu-item-title分区标题与lpx-notification-items列表;每条约 56px 高,左侧bi-bell-fill圆形成员图标,右侧Title(高亮加粗)+Content(截断单行)+ 时间小字;未读项带左侧色条/背景高亮。 - 底部操作区:全宽“See All Notifications”按钮(
lpx-button),点击跳转/notification/private-messages查看完整私信。 - 交互:悬停条目背景微变,按钮 hover 变色;面板展开带轻微淡入;空态显示“暂无通知”居中提示。整体响应式适配工具栏宽度。
It already got code as below, it is a component view here:.Web\Components\Toolbar\NotificationBadge\Default.cshtml
<li class="outer-menu-item" id="notification-bar"> <a href="javascript:;" class="lpx-menu-item-link" data-lpx-ctx-toggle="notification-bar"> <span class="lpx-menu-item-icon" style="position:relative;"> <i class="lpx-icon action-icon bi bi-bell-fill" aria-hidden="true"></i> <small class="menu-item-badge" id="UnreadCountBadge" data-count="0" style="opacity:0;visibility:hidden;">0</small> </span> </a>
<div class="lpx-context-menu" data-lpx-context-menu="notification-bar">
<ul class="lpx-nav-menu-notification" id="notification-menu">
<li class="outer-menu-item" id="notification-header">
<a class="lpx-menu-item-link lpx-menu-item">
<span class="lpx-menu-item-icon">
<i class="lpx-icon outer-icon bi bi-bell-fill" aria-hidden="true"></i>
</span>
<span class="lpx-menu-item-text">Notification
<span class="lpx-badge" id="NotificationNewBadge" data-count="0" style="opacity:0;visibility:hidden;">0 NEW</span>
</span>
<span data-lpx-close="notification-bar">
<i class="lpx-icon bi bi-x outer-icon dd-icon" aria-hidden="true"></i>
</span>
</a>
</li>
</ul>
<div class="lpx-button-container" id="notification-footer">
<button class="lpx-button notif-btn-outline" id="MarkAllAsReadBtn" type="button">Mark all as read</button>
<a class="lpx-button notif-btn-primary" href="/notification/private-messages">See All Notifications</a>
</div>
</div>
</li>
<script> (function() { 'use strict';
// Clean up previous instance (e.g., after SPA re-render)
if (window.__notificationPollTimer) {
clearInterval(window.__notificationPollTimer);
window.__notificationPollTimer = null;
}
if (window.__notificationDomHandler) {
try { abp.event.off('abp.domUpdated', window.__notificationDomHandler); } catch (e) {}
window.__notificationDomHandler = null;
}
if (window.__notificationClickHandler) {
document.removeEventListener('click', window.__notificationClickHandler);
window.__notificationClickHandler = null;
}
var currentUnread = 0;
// Scoped lookup: avoid stale duplicate-ID elements from previous SPA renders.
function getBadge() {
var bar = document.getElementById('notification-bar');
return bar ? bar.querySelector('.menu-item-badge') : null;
}
function getPanel() {
var bar = document.getElementById('notification-bar');
return bar ? bar.querySelector('.lpx-context-menu') : null;
}
function isPanelOpen() {
var p = getPanel();
return !!(p && p.classList.contains('show'));
}
function unwrap(response) {
if (response && typeof response === 'object' && 'result' in response) return response.result;
return response;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return { '&': '&', '<': '&lt;', '>': '>', '"': '"', "'": ''' }[c];
});
}
// Use data-count attribute (CSS-driven visibility) instead of display
// manipulation so LeptonX's DOM watcher won't revert our changes.
function updateBadge(count) {
var num = parseInt(count, 10) || 0;
currentUnread = num;
// jQuery lookups — ABP is jQuery-based; avoids stale vanilla references
var $badge = $('#notification-bar .menu-item-badge');
if ($badge.length) {
$badge.attr('data-count', num)
.text(num > 99 ? '99+' : String(num));
}
var $newBadge = $('#NotificationNewBadge');
if ($newBadge.length) {
$newBadge.attr('data-count', num > 0 ? num : 0)
.text(num > 0 ? num + ' NEW' : '0 NEW');
}
}
function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
function groupLabel(date) {
var today = startOfDay(new Date());
var yest = new Date(today);
yest.setDate(today.getDate() - 1);
if (date >= today) return 'TODAY';
if (date >= yest) return 'YESTERDAY';
var dd = String(date.getDate()).padStart(2, '0');
var mm = String(date.getMonth() + 1).padStart(2, '0');
return dd + '.' + mm + '.' + date.getFullYear();
}
function formatTime(date) {
var hh = String(date.getHours()).padStart(2, '0');
var mm = String(date.getMinutes()).padStart(2, '0');
var today = startOfDay(new Date());
var yest = new Date(today);
yest.setDate(today.getDate() - 1);
if (date >= today) return hh + ':' + mm;
if (date >= yest) return 'Yesterday ' + hh + ':' + mm;
var dd = String(date.getDate()).padStart(2, '0');
var mm2 = String(date.getMonth() + 1).padStart(2, '0');
return mm2 + '/' + dd + ' ' + hh + ':' + mm;
}
function renderGroups(items) {
var menu = document.getElementById('notification-menu');
if (!menu) return;
var old = menu.querySelectorAll('.notif-group');
for (var i = 0; i < old.length; i++) old[i].remove();
if (!items || !items.length) {
var emptyLi = document.createElement('li');
emptyLi.className = 'outer-menu-item notif-group';
emptyLi.innerHTML = '<div class="lpx-notification-empty">暂无通知</div>';
menu.appendChild(emptyLi);
return;
}
var groups = [];
items.forEach(function(it, idx) {
var d = new Date(it.creationTime);
var label = groupLabel(d);
var g = null;
for (var j = 0; j < groups.length; j++) {
if (groups[j].label === label) { g = groups[j]; break; }
}
if (!g) { g = { label: label, items: [] }; groups.push(g); }
g.items.push({ data: it, unread: idx < currentUnread, date: d });
});
groups.forEach(function(g) {
var li = document.createElement('li');
li.className = 'outer-menu-item notif-group';
var html = '<div class="lpx-menu-item-title">' + esc(g.label) + '</div>'
+ '<ul class="lpx-notification-items lpx-inner-menu">';
g.items.forEach(function(it) {
html += '<li class="lpx-notification-item' + (it.unread ? ' unread' : '') + '" data-id="' + esc(it.data.id) + '">'
+ '<a class="lpx-notification-item-link">'
+ '<div class="lpx-notification-member-container"><i class="lpx-notification-member-icon bi bi-bell-fill"></i></div>'
+ '<div><p class="lpx-notification-text"><span class="lpx-notification-text-highlight">' + esc(it.data.title)
+ '</span> ' + esc(it.data.content) + '</p>'
+ '<small class="lpx-notification-text-small">' + esc(formatTime(it.date)) + '</small></div>'
+ '</a></li>';
});
html += '</ul>';
li.innerHTML = html;
menu.appendChild(li);
});
}
function fetchLatest() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message?maxResultCount=5&sorting=CreationTime%20DESC',
type: 'GET',
success: function(response) {
var data = unwrap(response);
var items = (data && data.items) ? data.items : [];
renderGroups(items);
},
error: function() {}
});
}
function fetchCount() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/unread-count',
type: 'GET',
success: function(response) {
var num = parseInt(unwrap(response), 10) || 0;
updateBadge(num);
if (isPanelOpen()) fetchLatest();
},
error: function() {}
});
}
function markOneRead(id, liEl) {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/' + id + '/mark-as-read',
type: 'POST',
success: function() {
if (liEl) liEl.classList.remove('unread');
updateBadge(Math.max(0, currentUnread - 1));
},
error: function() {}
});
}
function markAllRead() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/mark-all-as-read',
type: 'POST',
success: function() {
var menu = document.getElementById('notification-menu');
if (menu) {
var unread = menu.querySelectorAll('.lpx-notification-item.unread');
for (var i = 0; i < unread.length; i++) unread[i].classList.remove('unread');
}
updateBadge(0);
},
error: function() {}
});
}
function startPolling() {
if (window.__notificationPollTimer) return;
fetchCount();
window.__notificationPollTimer = setInterval(fetchCount, 30000);
}
window.refreshNotificationBadge = fetchCount;
window.refreshNotificationList = fetchLatest;
// Event delegation (survives SPA re-renders)
window.__notificationClickHandler = function(e) {
if (!e.target || !e.target.closest) return;
if (e.target.closest('[data-lpx-ctx-toggle="notification-bar"]')) {
fetchLatest();
return;
}
if (e.target.closest('#MarkAllAsReadBtn')) {
markAllRead();
return;
}
var itemEl = e.target.closest('.lpx-notification-item');
if (itemEl && itemEl.getAttribute('data-id')) {
markOneRead(itemEl.getAttribute('data-id'), itemEl);
}
};
document.addEventListener('click', window.__notificationClickHandler);
function tryInit() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return false;
window.__notificationDomHandler = fetchCount;
abp.event.on('abp.domUpdated', window.__notificationDomHandler);
startPolling();
return true;
}
if (!tryInit()) {
var attempts = 0;
var readyTimer = setInterval(function() {
attempts++;
if (tryInit()) {
clearInterval(readyTimer);
} else if (attempts > 40) {
clearInterval(readyTimer);
}
}, 500);
}
})(); </script>
Virtaul DOM generated, but cannot update the real DOM
4 Answer(s)
-
0
In LeptonX (MVC) the topbar/sidebar UI pieces are re-initialized after partial page updates (ABP’s dynamic DOM updates / SPA-like navigation). When that happens, LeptonX can replace (or re-render) the menu/link/badge elements, so the elements you updated are no longer the “live” ones. The symptom is exactly what you described: you see the visual DOM once, but later your updates don’t reflect, because you’re updating a stale node or your changes are overwritten by the theme’s initializer.
Fix: Treat LeptonX-rendered DOM as disposable and always re-apply your badge/list rendering after ABP DOM updates using ABP’s DOM API, and always query the DOM at the time you update (never cache element references). Concretely:
- Wrap all your “apply UI state” into a single function (
applyUnreadCountToDom). - Call it after every fetch and inside
abp.dom.onNodeAdded(ABP DOM API) so it runs when LeptonX/ABP re-adds the toolbar node. - Avoid duplicate IDs across re-renders; scope your selector under
#notification-bar(you already started doing this—keep it everywhere, including#NotificationNewBadge, which can also become duplicated if the toolbar re-renders).
- Wrap all your “apply UI state” into a single function (
Here is the minimal pattern that works reliably with LeptonX + ABP dynamic DOM updates (drop-in concept; adapt into your current script):
<script> (function () { 'use strict'; var state = { unread: 0 }; function applyUnreadCountToDom() { // Always re-query (never keep old references) var bar = document.getElementById('notification-bar'); if (!bar) return; var badge = bar.querySelector('.menu-item-badge'); if (badge) { badge.setAttribute('data-count', state.unread); badge.textContent = state.unread > 99 ? '99+' : String(state.unread); // If your CSS uses opacity/visibility, toggle them here too (optional) if (state.unread > 0) { badge.style.opacity = '1'; badge.style.visibility = 'visible'; } else { badge.style.opacity = '0'; badge.style.visibility = 'hidden'; } } // IMPORTANT: scope this too (don’t use global #NotificationNewBadge if toolbar can re-render) var newBadge = bar.querySelector('#NotificationNewBadge'); if (newBadge) { newBadge.setAttribute('data-count', state.unread); newBadge.textContent = state.unread + ' NEW'; newBadge.style.opacity = state.unread > 0 ? '1' : '0'; newBadge.style.visibility = state.unread > 0 ? 'visible' : 'hidden'; } } function fetchCount() { if (!window.abp?.ajax) return; abp.ajax({ url: (abp.appPath || '') + 'api/notification/private-message/unread-count', type: 'GET', success: function (response) { var num = parseInt((response && response.result != null) ? response.result : response, 10) || 0; state.unread = num; applyUnreadCountToDom(); } }); } // 1) Initial run fetchCount(); // 2) Re-apply after ABP adds/replaces DOM nodes (LeptonX/toolbar redraw) // This is the key difference vs. only using abp.event / setInterval. if (window.abp?.dom?.onNodeAdded) { abp.dom.onNodeAdded(function (node) { // Only react when our toolbar node is added/replaced if (node && node.id === 'notification-bar') { applyUnreadCountToDom(); } }); } // 3) Polling (as you already do) window.__notificationPollTimer = window.__notificationPollTimer || setInterval(fetchCount, 30000); })(); </script>What this changes vs your current approach:
- Your current logic mostly relies on
abp.event.on('abp.domUpdated', ...). In LeptonX scenarios, the safer hook is ABP DOM API node-added detection (abp.dom.onNodeAdded), because it runs when the actual node is reinserted/replaced, and you immediately re-apply your state to the fresh DOM. - It also fixes the common “duplicate IDs after toolbar re-render” issue by scoping everything under
#notification-bar.
If you still want to keep
abp.event.on('abp.domUpdated', ...), keep it, but you should still add theabp.dom.onNodeAddedhook above; that’s the part that makes the DOM updates deterministic with LeptonX redraws.Sources (verified):
- https://abp.io/docs/10.5/framework/ui/mvc-razor-pages/javascript-api/dom
- https://abp.io/docs/10.5/framework/ui/common/leptonx-css-variables
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Your JavaScript is actually updating the DOM — it's just updating a copy you can't see, and the badge itself never becomes visible. Three separate things are going on here:
- The LeptonX MVC layout renders every toolbar item twice: once in the toolbar and once more inside the hidden mobile menu, and the mobile copy comes first in the DOM. Since your markup uses
idattributes, the ids get duplicated anddocument.getElementById(...)always resolves to the hidden mobile copy — the notification list is injected into the invisible panel while the visible one stays empty. - LeptonX has no
data-countbased CSS rules, so the inlinestyle="opacity:0;visibility:hidden;"on your badges is never undone. Besides that, the theme only gives.menu-item-badgeits badge styling (position, background, size) when the parent link has theactive-menu-itemclass — without that class it's just an unstyled<small>element. - The theme already wraps every toolbar item in
<li class="outer-menu-item">. Your component's own outer<li>can't be nested inside it, so the browser closes the theme's<li>early and you end up with an extra empty<li>in the toolbar.
Here is a corrected
Default.cshtml: no ids (all DOM lookups go through classes and update both rendered copies),active-menu-itemtoggled for the badge styling andd-nonefor its visibility, no outer<li>, and a guard at the top so the inline script (which is also included twice) only runs once per page:<a href="javascript:;" class="lpx-menu-item-link notification-bell" data-lpx-ctx-toggle="notification-bar"> <span class="lpx-menu-item-icon" style="position:relative;"> <i class="lpx-icon action-icon bi bi-bell-fill" aria-hidden="true"></i> <small class="menu-item-badge notification-count d-none">0</small> </span> </a> <div class="lpx-context-menu" data-lpx-context-menu="notification-bar"> <ul class="lpx-nav-menu-notification notification-menu"> <li class="outer-menu-item"> <a class="lpx-menu-item-link lpx-menu-item"> <span class="lpx-menu-item-icon"> <i class="lpx-icon outer-icon bi bi-bell-fill" aria-hidden="true"></i> </span> <span class="lpx-menu-item-text">Notification <span class="lpx-badge notification-new-badge d-none">0 NEW</span> </span> <span data-lpx-close="notification-bar"> <i class="lpx-icon bi bi-x outer-icon dd-icon" aria-hidden="true"></i> </span> </a> </li> </ul> <div class="lpx-button-container"> <button class="lpx-button notif-btn-outline notification-mark-all" type="button">Mark all as read</button> <a class="lpx-button notif-btn-primary" href="/notification/private-messages">See All Notifications</a> </div> </div> <script> (function() { 'use strict'; // The theme renders each toolbar item twice (toolbar + mobile menu), // so this script is also included twice per page - run it only once. if (window.__notificationBadgeInitialized) return; window.__notificationBadgeInitialized = true; var currentUnread = 0; function unwrap(response) { if (response && typeof response === 'object' && 'result' in response) return response.result; return response; } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function isPanelOpen() { return !!document.querySelector('[data-lpx-context-menu="notification-bar"].show'); } function updateBadge(count) { var num = parseInt(count, 10) || 0; currentUnread = num; document.querySelectorAll('.notification-bell').forEach(function(link) { link.classList.toggle('active-menu-item', num > 0); var badge = link.querySelector('.notification-count'); if (badge) { badge.textContent = num > 99 ? '99+' : String(num); badge.classList.toggle('d-none', num <= 0); } }); document.querySelectorAll('.notification-new-badge').forEach(function(badge) { badge.textContent = num + ' NEW'; badge.classList.toggle('d-none', num <= 0); }); } function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function groupLabel(date) { var today = startOfDay(new Date()); var yest = new Date(today); yest.setDate(today.getDate() - 1); if (date >= today) return 'TODAY'; if (date >= yest) return 'YESTERDAY'; var dd = String(date.getDate()).padStart(2, '0'); var mm = String(date.getMonth() + 1).padStart(2, '0'); return dd + '.' + mm + '.' + date.getFullYear(); } function formatTime(date) { var hh = String(date.getHours()).padStart(2, '0'); var mm = String(date.getMinutes()).padStart(2, '0'); var today = startOfDay(new Date()); var yest = new Date(today); yest.setDate(today.getDate() - 1); if (date >= today) return hh + ':' + mm; if (date >= yest) return 'Yesterday ' + hh + ':' + mm; var dd = String(date.getDate()).padStart(2, '0'); var mm2 = String(date.getMonth() + 1).padStart(2, '0'); return mm2 + '/' + dd + ' ' + hh + ':' + mm; } function renderGroups(items) { document.querySelectorAll('.notification-menu').forEach(function(menu) { menu.querySelectorAll('.notif-group').forEach(function(g) { g.remove(); }); if (!items || !items.length) { var emptyLi = document.createElement('li'); emptyLi.className = 'outer-menu-item notif-group'; emptyLi.innerHTML = '<div class="lpx-notification-empty">No notifications</div>'; menu.appendChild(emptyLi); return; } var groups = []; items.forEach(function(it, idx) { var d = new Date(it.creationTime); var label = groupLabel(d); var g = null; for (var j = 0; j < groups.length; j++) { if (groups[j].label === label) { g = groups[j]; break; } } if (!g) { g = { label: label, items: [] }; groups.push(g); } g.items.push({ data: it, unread: idx < currentUnread, date: d }); }); groups.forEach(function(g) { var li = document.createElement('li'); li.className = 'outer-menu-item notif-group'; var html = '<div class="lpx-menu-item-title">' + esc(g.label) + '</div>' + '<ul class="lpx-notification-items lpx-inner-menu">'; g.items.forEach(function(it) { html += '<li class="lpx-notification-item' + (it.unread ? ' unread' : '') + '" data-id="' + esc(it.data.id) + '">' + '<a class="lpx-notification-item-link">' + '<div class="lpx-notification-member-container"><i class="lpx-notification-member-icon bi bi-bell-fill"></i></div>' + '<div><p class="lpx-notification-text"><span class="lpx-notification-text-highlight">' + esc(it.data.title) + '</span> ' + esc(it.data.content) + '</p>' + '<small class="lpx-notification-text-small">' + esc(formatTime(it.date)) + '</small></div>' + '</a></li>'; }); html += '</ul>'; li.innerHTML = html; menu.appendChild(li); }); }); } function fetchLatest() { if (typeof abp === 'undefined' || !abp || !abp.ajax) return; abp.ajax({ url: (abp.appPath || '') + 'api/notification/private-message?maxResultCount=5&sorting=CreationTime%20DESC', type: 'GET', success: function(response) { var data = unwrap(response); var items = (data && data.items) ? data.items : []; renderGroups(items); }, error: function() {} }); } function fetchCount() { if (typeof abp === 'undefined' || !abp || !abp.ajax) return; abp.ajax({ url: (abp.appPath || '') + 'api/notification/private-message/unread-count', type: 'GET', success: function(response) { var num = parseInt(unwrap(response), 10) || 0; updateBadge(num); if (isPanelOpen()) fetchLatest(); }, error: function() {} }); } function markOneRead(id) { if (typeof abp === 'undefined' || !abp || !abp.ajax) return; abp.ajax({ url: (abp.appPath || '') + 'api/notification/private-message/' + id + '/mark-as-read', type: 'POST', success: function() { document.querySelectorAll('.lpx-notification-item[data-id="' + id + '"]').forEach(function(el) { el.classList.remove('unread'); }); fetchCount(); }, error: function() {} }); } function markAllRead() { if (typeof abp === 'undefined' || !abp || !abp.ajax) return; abp.ajax({ url: (abp.appPath || '') + 'api/notification/private-message/mark-all-as-read', type: 'POST', success: function() { document.querySelectorAll('.notification-menu .lpx-notification-item.unread').forEach(function(el) { el.classList.remove('unread'); }); updateBadge(0); }, error: function() {} }); } window.refreshNotificationBadge = fetchCount; window.refreshNotificationList = fetchLatest; document.addEventListener('click', function(e) { if (!e.target || !e.target.closest) return; if (e.target.closest('[data-lpx-ctx-toggle="notification-bar"]')) { fetchLatest(); return; } if (e.target.closest('.notification-mark-all')) { markAllRead(); return; } var itemEl = e.target.closest('.lpx-notification-item'); if (itemEl && itemEl.classList.contains('unread') && itemEl.getAttribute('data-id')) { itemEl.classList.remove('unread'); markOneRead(itemEl.getAttribute('data-id')); } }); function tryInit() { if (typeof abp === 'undefined' || !abp || !abp.ajax) return false; fetchCount(); setInterval(fetchCount, 30000); return true; } if (!tryInit()) { var attempts = 0; var readyTimer = setInterval(function() { attempts++; if (tryInit()) { clearInterval(readyTimer); } else if (attempts > 40) { clearInterval(readyTimer); } }, 500); } })(); </script>Keep
data-lpx-ctx-toggle="notification-bar"/data-lpx-context-menu="notification-bar"as they are —notification-baris the context menu name LeptonX binds automatically inside the toolbar, so the dropdown toggling in the toolbar keeps working. Note this covers the desktop toolbar; the copy inside the mobile menu is kept in sync data-wise, but the dropdown there is not wired up by the theme.One more detail for the footer: the theme's
lpx-button-containerstyles a single full-width button (that's also why yournotif-btn-outline/notif-btn-primaryclasses have no effect — they don't exist in LeptonX), so your two buttons overlap. Add this to your styles to stack them properly:.lpx-context-menu .lpx-button-container .lpx-button { display: block; text-align: center; text-decoration: none; box-sizing: border-box; } .lpx-context-menu .lpx-button-container .lpx-button + .lpx-button { margin-top: 8px; }Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - The LeptonX MVC layout renders every toolbar item twice: once in the toolbar and once more inside the hidden mobile menu, and the mobile copy comes first in the DOM. Since your markup uses