use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::infrastructure::notifications::{ dismiss_notification, fetch_notification_count, fetch_notifications, mark_all_notifications_read, }; #[component] pub fn NotificationBell() -> Element { let mut is_open = use_signal(|| false); let mut count = use_signal(|| 0u64); let mut notifications = use_signal(Vec::new); let mut is_loading = use_signal(|| false); // Poll notification count every 30 seconds use_resource(move || async move { loop { if let Ok(c) = fetch_notification_count().await { count.set(c); } #[cfg(feature = "web")] { gloo_timers::future::TimeoutFuture::new(30_000).await; } #[cfg(not(feature = "web"))] { tokio::time::sleep(std::time::Duration::from_secs(30)).await; } } }); // Load notifications when panel opens let load_notifications = move |_| { is_open.set(!is_open()); if !is_open() { return; } is_loading.set(true); spawn(async move { if let Ok(resp) = fetch_notifications().await { notifications.set(resp.data); } // Mark all as read when panel opens let _ = mark_all_notifications_read().await; count.set(0); is_loading.set(false); }); }; let on_dismiss = move |id: String| { spawn(async move { let _ = dismiss_notification(id.clone()).await; notifications.write().retain(|n| { n.id.as_ref() .and_then(|v| v.get("$oid")) .and_then(|v| v.as_str()) != Some(&id) }); }); }; rsx! { div { class: "notification-bell-wrapper", // Bell button button { class: "notification-bell-btn", onclick: load_notifications, title: "CVE Alerts", Icon { icon: BsBell, width: 18, height: 18 } if count() > 0 { span { class: "notification-badge", "{count()}" } } } // Dropdown panel if is_open() { div { class: "notification-panel", div { class: "notification-panel-header", span { "CVE Alerts" } button { class: "notification-close-btn", onclick: move |_| is_open.set(false), Icon { icon: BsX, width: 16, height: 16 } } } div { class: "notification-panel-body", if is_loading() { div { class: "notification-loading", "Loading..." } } else if notifications().is_empty() { div { class: "notification-empty", Icon { icon: BsShieldCheck, width: 32, height: 32 } p { "No CVE alerts" } } } else { for notif in notifications().iter() { { let id = notif.id.as_ref() .and_then(|v| v.get("$oid")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let sev_class = match notif.severity.as_str() { "critical" => "sev-critical", "high" => "sev-high", "medium" => "sev-medium", _ => "sev-low", }; let dismiss_id = id.clone(); rsx! { div { class: "notification-item", div { class: "notification-item-header", span { class: "notification-sev {sev_class}", "{notif.severity.to_uppercase()}" } span { class: "notification-cve-id", if let Some(ref url) = notif.url { a { href: "{url}", target: "_blank", "{notif.cve_id}" } } else { "{notif.cve_id}" } } if let Some(score) = notif.cvss_score { span { class: "notification-cvss", "CVSS {score:.1}" } } button { class: "notification-dismiss-btn", title: "Dismiss", onclick: move |_| on_dismiss(dismiss_id.clone()), Icon { icon: BsXCircle, width: 14, height: 14 } } } div { class: "notification-item-pkg", "{notif.package_name} {notif.package_version}" } div { class: "notification-item-repo", "{notif.repo_name}" } if let Some(ref summary) = notif.summary { div { class: "notification-item-summary", "{summary}" } } } } } } } } } } } } }