feat(alerts): refactor AlertsBell component to support dynamic positioning and improve alert display with a new panel layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
|
||||
import { useAlerts } from '../contexts/AlertsContext.jsx';
|
||||
@@ -21,11 +23,182 @@ function AlertIcon({ type, severity }) {
|
||||
return <Icon size={18} className={colorClass} />;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 320;
|
||||
const PANEL_MAX_HEIGHT = 320;
|
||||
const Z_INDEX = 1050;
|
||||
|
||||
function AlertsPanelContent({ loading, refresh, alerts, onClose }) {
|
||||
return (
|
||||
<>
|
||||
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
|
||||
<span className="fw-semibold">Оповещения</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-sm"
|
||||
onClick={(e) => { e.preventDefault(); refresh(); }}
|
||||
disabled={loading}
|
||||
title="Обновить"
|
||||
>
|
||||
{loading ? '…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: `${PANEL_MAX_HEIGHT}px`, overflowY: 'auto' }}>
|
||||
{alerts.length === 0 && !loading && (
|
||||
<div className="list-group-item text-muted small text-center py-3">
|
||||
Нет активных оповещений
|
||||
</div>
|
||||
)}
|
||||
{alerts.slice(0, 20).map((alert) => (
|
||||
<Link
|
||||
key={alert.id}
|
||||
to={alert.link || '/dashboard'}
|
||||
className="list-group-item list-group-item-action py-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-2">
|
||||
<span className="flex-shrink-0 mt-1">
|
||||
<AlertIcon type={alert.type} severity={alert.severity} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-grow-1">
|
||||
<div className="fw-medium small">{alert.title}</div>
|
||||
<div className="text-muted small text-truncate" title={alert.description}>
|
||||
{alert.entity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{alerts.length > 20 && (
|
||||
<div className="list-group-item text-muted small text-center py-2">
|
||||
и ещё {alerts.length - 20}…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer py-2 px-3 d-flex align-items-center gap-2 flex-nowrap">
|
||||
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap" onClick={onClose}>
|
||||
Настройки
|
||||
</Link>
|
||||
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap" onClick={onClose}>
|
||||
Панель
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertsBell({ dropup = false }) {
|
||||
const { alerts, total, loading, refresh } = useAlerts();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = () => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const padding = 4;
|
||||
const maxLeft = window.innerWidth - PANEL_WIDTH - padding;
|
||||
if (dropup) {
|
||||
setPosition({
|
||||
left: Math.max(padding, Math.min(rect.left, maxLeft)),
|
||||
bottom: window.innerHeight - rect.top + padding,
|
||||
});
|
||||
} else {
|
||||
setPosition({
|
||||
top: rect.bottom + padding,
|
||||
left: Math.max(padding, Math.min(rect.left, maxLeft)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) updatePosition();
|
||||
}, [open, dropup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = () => updatePosition();
|
||||
const onResize = () => updatePosition();
|
||||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||
const onClickOutside = (e) => {
|
||||
const panel = panelRef.current;
|
||||
const trigger = triggerRef.current;
|
||||
if (!panel || !trigger) return;
|
||||
if (!panel.contains(e.target) && !trigger.contains(e.target)) setOpen(false);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleTriggerClick = (e) => {
|
||||
e.preventDefault();
|
||||
if (dropup) {
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
if (dropup) {
|
||||
return (
|
||||
<>
|
||||
<div className="nav-item">
|
||||
<a
|
||||
ref={triggerRef}
|
||||
href="#"
|
||||
className="nav-link px-2 d-inline-flex align-items-center justify-content-center"
|
||||
onClick={handleTriggerClick}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-label={`Оповещения: ${total} активных`}
|
||||
title="Оповещения о проблемах"
|
||||
>
|
||||
<span className="position-relative d-inline-flex">
|
||||
<IconBell size={20} />
|
||||
{total > 0 && (
|
||||
<span className="badge badge-sm bg-danger position-absolute rounded-pill" style={{ top: '-2px', right: '-2px' }}>
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{open && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="dropdown-menu dropdown-menu-card show overflow-hidden shadow-lg border"
|
||||
data-bs-theme="light"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
...(position.bottom !== undefined ? { bottom: position.bottom } : { top: position.top }),
|
||||
width: `${PANEL_WIDTH}px`,
|
||||
maxWidth: 'calc(100vw - 1rem)',
|
||||
zIndex: Z_INDEX,
|
||||
}}
|
||||
>
|
||||
<AlertsPanelContent
|
||||
loading={loading}
|
||||
refresh={refresh}
|
||||
alerts={alerts}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`nav-item dropdown${dropup ? ' dropup' : ''}`}>
|
||||
<div className="nav-item dropdown">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
|
||||
@@ -43,58 +216,8 @@ export default function AlertsBell({ dropup = false }) {
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: '320px', maxWidth: '95vw' }}>
|
||||
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
|
||||
<span className="fw-semibold">Оповещения</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-sm"
|
||||
onClick={(e) => { e.preventDefault(); refresh(); }}
|
||||
disabled={loading}
|
||||
title="Обновить"
|
||||
>
|
||||
{loading ? '…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-group list-group-flush list-group-hoverable" style={{ maxHeight: '320px', overflowY: 'auto' }}>
|
||||
{alerts.length === 0 && !loading && (
|
||||
<div className="list-group-item text-muted small text-center py-3">
|
||||
Нет активных оповещений
|
||||
</div>
|
||||
)}
|
||||
{alerts.slice(0, 20).map((alert) => (
|
||||
<Link
|
||||
key={alert.id}
|
||||
to={alert.link || '/dashboard'}
|
||||
className="list-group-item list-group-item-action py-2"
|
||||
>
|
||||
<div className="d-flex align-items-start gap-2">
|
||||
<span className="flex-shrink-0 mt-1">
|
||||
<AlertIcon type={alert.type} severity={alert.severity} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-grow-1">
|
||||
<div className="fw-medium small">{alert.title}</div>
|
||||
<div className="text-muted small text-truncate" title={alert.description}>
|
||||
{alert.entity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{alerts.length > 20 && (
|
||||
<div className="list-group-item text-muted small text-center py-2">
|
||||
и ещё {alerts.length - 20}…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer py-2 px-3 d-flex align-items-center gap-2 flex-nowrap">
|
||||
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary text-nowrap">
|
||||
Настройки
|
||||
</Link>
|
||||
<Link to="/dashboard" className="btn btn-sm btn-outline-primary text-nowrap">
|
||||
Панель
|
||||
</Link>
|
||||
</div>
|
||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
|
||||
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user