From 00d977dfb5036cf1b3b3d035fb0f71bd7e0f253f Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 24 Feb 2026 01:40:41 +0700 Subject: [PATCH] feat(alerts): refactor AlertsBell component to support dynamic positioning and improve alert display with a new panel layout --- frontend/src/components/AlertsBell.jsx | 229 +++++++++++++++++++------ 1 file changed, 176 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/AlertsBell.jsx b/frontend/src/components/AlertsBell.jsx index 52dc993..4302632 100644 --- a/frontend/src/components/AlertsBell.jsx +++ b/frontend/src/components/AlertsBell.jsx @@ -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 ; } +const PANEL_WIDTH = 320; +const PANEL_MAX_HEIGHT = 320; +const Z_INDEX = 1050; + +function AlertsPanelContent({ loading, refresh, alerts, onClose }) { + return ( + <> +
+ Оповещения + +
+
+ {alerts.length === 0 && !loading && ( +
+ Нет активных оповещений +
+ )} + {alerts.slice(0, 20).map((alert) => ( + +
+ + + +
+
{alert.title}
+
+ {alert.entity} +
+
+
+ + ))} + {alerts.length > 20 && ( +
+ и ещё {alerts.length - 20}… +
+ )} +
+
+ + Настройки + + + Панель + +
+ + ); +} + 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 ( + <> +
+ + + + {total > 0 && ( + + {total > 99 ? '99+' : total} + + )} + + +
+ {open && typeof document !== 'undefined' && createPortal( +
+ setOpen(false)} + /> +
, + document.body + )} + + ); + } return ( -
+
-
-
- Оповещения - -
-
- {alerts.length === 0 && !loading && ( -
- Нет активных оповещений -
- )} - {alerts.slice(0, 20).map((alert) => ( - -
- - - -
-
{alert.title}
-
- {alert.entity} -
-
-
- - ))} - {alerts.length > 20 && ( -
- и ещё {alerts.length - 20}… -
- )} -
-
- - Настройки - - - Панель - -
+
+ {}} />
);