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 { Link } from 'react-router-dom';
|
||||||
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
|
import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react';
|
||||||
import { useAlerts } from '../contexts/AlertsContext.jsx';
|
import { useAlerts } from '../contexts/AlertsContext.jsx';
|
||||||
@@ -21,11 +23,182 @@ function AlertIcon({ type, severity }) {
|
|||||||
return <Icon size={18} className={colorClass} />;
|
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 }) {
|
export default function AlertsBell({ dropup = false }) {
|
||||||
const { alerts, total, loading, refresh } = useAlerts();
|
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 (
|
return (
|
||||||
<div className={`nav-item dropdown${dropup ? ' dropup' : ''}`}>
|
<div className="nav-item dropdown">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
className="nav-link px-2 dropdown-toggle position-relative d-inline-flex align-items-center justify-content-center"
|
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>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: '320px', maxWidth: '95vw' }}>
|
<div className="dropdown-menu dropdown-menu-end dropdown-menu-card overflow-hidden" style={{ width: `${PANEL_WIDTH}px`, maxWidth: '95vw' }}>
|
||||||
<div className="card-header d-flex align-items-center justify-content-between py-2 px-3">
|
<AlertsPanelContent loading={loading} refresh={refresh} alerts={alerts} onClose={() => {}} />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user