"use client"; import { HEALTH_URL } from "@/lib/api"; import { useCurrentUser } from "@/lib/auth-state"; import { navigation } from "@/lib/navigation"; import { addPreferencesListener, getStoredTheme, resolveThemePreference, setStoredTheme, } from "@/lib/preferences"; import { hasMinimumRole } from "@/lib/permissions"; import { Bell, LoaderCircle, Menu, Moon, RefreshCw, Search, SunMedium, Wifi, WifiOff, } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type ConnectionState = "checking" | "online" | "offline" | "degraded"; type NotificationTone = "info" | "success" | "warning" | "error"; interface TopbarNotification { id: string; title: string; message: string; tone: NotificationTone; timestamp: number; read: boolean; } const TOPBAR_NOTIFICATIONS_KEY = "portainer-topbar-notifications"; const MAX_TOPBAR_NOTIFICATIONS = 5; interface TopbarProps { title: string; subtitle?: string; onMobileMenuToggle?: () => void; } export default function Topbar({ title, subtitle, onMobileMenuToggle, }: TopbarProps) { const currentUser = useCurrentUser(); const pathname = usePathname(); const router = useRouter(); const [time, setTime] = useState(""); const [connectionState, setConnectionState] = useState("checking"); const [query, setQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark"); const [notifications, setNotifications] = useState([]); const [notificationOpen, setNotificationOpen] = useState(false); const searchRef = useRef(null); const searchShellRef = useRef(null); const notificationShellRef = useRef(null); const notificationsRef = useRef([]); const lastConnectionNotificationRef = useRef(null); const updateNotifications = useCallback( (updater: (current: TopbarNotification[]) => TopbarNotification[]) => { setNotifications((current) => { const next = updater(current).slice(0, MAX_TOPBAR_NOTIFICATIONS); notificationsRef.current = next; window.localStorage.setItem( TOPBAR_NOTIFICATIONS_KEY, JSON.stringify(next), ); return next; }); }, [], ); const pushNotification = useCallback( ({ title: nextTitle, message, tone = "info", }: Omit) => { updateNotifications((current) => [ { id: `topbar-notification-${Date.now()}-${current.length}`, title: nextTitle, message, tone, timestamp: Date.now(), read: false, }, ...current, ]); }, [updateNotifications], ); const markNotificationsRead = useCallback(() => { updateNotifications((current) => { if (current.every((notification) => notification.read)) { return current; } return current.map((notification) => ({ ...notification, read: true, })); }); }, [updateNotifications]); useEffect(() => { try { const stored = window.localStorage.getItem(TOPBAR_NOTIFICATIONS_KEY); if (stored) { const parsed = JSON.parse(stored) as TopbarNotification[]; const initial = parsed.slice(0, MAX_TOPBAR_NOTIFICATIONS); setNotifications(initial); notificationsRef.current = initial; return; } } catch { window.localStorage.removeItem(TOPBAR_NOTIFICATIONS_KEY); } const now = Date.now(); const seeded: TopbarNotification[] = [ { id: "seed-1", title: "Welcome back", message: currentUser?.name ? `${currentUser.name}, your workspace is ready.` : "Your workspace is ready.", tone: "info", timestamp: now - 5 * 60_000, read: false, }, { id: "seed-2", title: "Quick search enabled", message: "Press Ctrl/Cmd + K to jump between dashboard pages.", tone: "info", timestamp: now - 4 * 60_000, read: false, }, { id: "seed-3", title: "Theme switch ready", message: "Use the sun or moon toggle to change the panel theme.", tone: "info", timestamp: now - 3 * 60_000, read: false, }, { id: "seed-4", title: "Connection monitor active", message: "The topbar now checks browser and API availability automatically.", tone: "success", timestamp: now - 2 * 60_000, read: false, }, { id: "seed-5", title: "Notification center active", message: "The bell button shows the latest 5 topbar notifications.", tone: "success", timestamp: now - 60_000, read: false, }, ]; setNotifications(seeded); notificationsRef.current = seeded; window.localStorage.setItem( TOPBAR_NOTIFICATIONS_KEY, JSON.stringify(seeded), ); }, [currentUser?.name]); useEffect(() => { const update = () => { const now = new Date(); setTime( now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", }), ); }; update(); const interval = setInterval(update, 1000); return () => clearInterval(interval); }, []); useEffect(() => { const syncTheme = () => { setResolvedTheme(resolveThemePreference(getStoredTheme())); }; syncTheme(); const media = window.matchMedia("(prefers-color-scheme: light)"); const onThemeMediaChange = () => syncTheme(); media.addEventListener("change", onThemeMediaChange); const removePreferencesListener = addPreferencesListener(syncTheme); return () => { media.removeEventListener("change", onThemeMediaChange); removePreferencesListener(); }; }, []); const visibleRoutes = useMemo( () => navigation .flatMap((section) => section.items.map((item) => ({ ...item, sectionLabel: section.label, })), ) .filter((item) => hasMinimumRole(currentUser?.role, item.minRole)), [currentUser?.role], ); const filteredRoutes = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) { return visibleRoutes.slice(0, 8); } return visibleRoutes .filter((item) => { const haystack = [ item.label, item.href, item.sectionLabel, ...(item.keywords ?? []), ] .join(" ") .toLowerCase(); return haystack.includes(normalized); }) .slice(0, 8); }, [query, visibleRoutes]); const checkConnection = useCallback(async () => { if (typeof navigator !== "undefined" && !navigator.onLine) { setConnectionState("offline"); return; } setConnectionState("checking"); const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 5000); try { const response = await fetch(HEALTH_URL, { method: "GET", cache: "no-store", signal: controller.signal, }); setConnectionState(response.ok ? "online" : "degraded"); } catch { setConnectionState("degraded"); } finally { window.clearTimeout(timeoutId); } }, []); useEffect(() => { void checkConnection(); const handleOnline = () => { void checkConnection(); }; const handleOffline = () => { setConnectionState("offline"); }; const interval = window.setInterval(() => { void checkConnection(); }, 30000); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.clearInterval(interval); window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, [checkConnection]); useEffect(() => { const onPointerDown = (event: MouseEvent) => { const target = event.target as Node; if (!searchShellRef.current?.contains(target)) { setSearchOpen(false); } if (!notificationShellRef.current?.contains(target)) { setNotificationOpen(false); } }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setSearchOpen(false); setNotificationOpen(false); return; } const shortcutPressed = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; if (!shortcutPressed) return; event.preventDefault(); setNotificationOpen(false); setSearchOpen(true); window.setTimeout(() => { searchRef.current?.focus(); searchRef.current?.select(); }, 0); }; document.addEventListener("pointerdown", onPointerDown); window.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("keydown", onKeyDown); }; }, []); useEffect(() => { setQuery(""); setSearchOpen(false); setNotificationOpen(false); }, [pathname]); useEffect(() => { if (!notificationOpen) { return; } markNotificationsRead(); }, [markNotificationsRead, notificationOpen]); const shortcutLabel = useMemo(() => { if (typeof navigator !== "undefined" && /mac/i.test(navigator.platform)) { return ["Cmd", "K"] as const; } return ["Ctrl", "K"] as const; }, []); const connectionMeta = { checking: { label: "Checking panel connection", color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)", border: "rgba(245, 158, 11, 0.3)", icon: , }, online: { label: "Browser online and API reachable", color: "#10b981", background: "rgba(16, 185, 129, 0.1)", border: "rgba(16, 185, 129, 0.3)", icon: , }, offline: { label: "Browser offline. Check your network.", color: "#ef4444", background: "rgba(239, 68, 68, 0.1)", border: "rgba(239, 68, 68, 0.3)", icon: , }, degraded: { label: "Browser online, but backend API is not responding", color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)", border: "rgba(245, 158, 11, 0.3)", icon: , }, }[connectionState]; const handleNavigate = (href: string) => { setSearchOpen(false); setNotificationOpen(false); setQuery(""); router.push(href); }; useEffect(() => { if (connectionState === "checking") { return; } if (lastConnectionNotificationRef.current === connectionState) { return; } lastConnectionNotificationRef.current = connectionState; if (connectionState === "online") { pushNotification({ title: "Connection restored", message: "Browser is online and the backend API is reachable.", tone: "success", }); return; } if (connectionState === "offline") { pushNotification({ title: "Browser offline", message: "Network access was lost. Reconnect to restore dashboard features.", tone: "error", }); return; } pushNotification({ title: "Backend degraded", message: "Browser is online, but the API health check did not complete cleanly.", tone: "warning", }); }, [connectionState, pushNotification]); const handleThemeToggle = () => { const nextTheme = resolvedTheme === "light" ? "dark" : "light"; setStoredTheme(nextTheme); pushNotification({ title: "Theme updated", message: `Dashboard theme switched to ${nextTheme} mode.`, tone: "info", }); }; const unreadCount = useMemo( () => notifications.filter((notification) => !notification.read).length, [notifications], ); const notificationToneMeta: Record< NotificationTone, { dot: string; border: string; background: string } > = { info: { dot: "#3b82f6", border: "rgba(59, 130, 246, 0.2)", background: "rgba(59, 130, 246, 0.08)", }, success: { dot: "#10b981", border: "rgba(16, 185, 129, 0.2)", background: "rgba(16, 185, 129, 0.08)", }, warning: { dot: "#f59e0b", border: "rgba(245, 158, 11, 0.2)", background: "rgba(245, 158, 11, 0.08)", }, error: { dot: "#ef4444", border: "rgba(239, 68, 68, 0.2)", background: "rgba(239, 68, 68, 0.08)", }, }; const formatNotificationTimestamp = (timestamp: number) => { const diff = Date.now() - timestamp; if (diff < 60_000) { return "Just now"; } if (diff < 3_600_000) { return `${Math.floor(diff / 60_000)}m ago`; } if (diff < 86_400_000) { return `${Math.floor(diff / 3_600_000)}h ago`; } return new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric", }); }; const handleRefresh = () => { const nextNotifications = [ { id: `topbar-notification-${Date.now()}-${notificationsRef.current.length}`, title: "Dashboard refresh", message: "The current page is reloading.", tone: "info" as const, timestamp: Date.now(), read: false, }, ...notificationsRef.current, ].slice(0, MAX_TOPBAR_NOTIFICATIONS); notificationsRef.current = nextNotifications; window.localStorage.setItem( TOPBAR_NOTIFICATIONS_KEY, JSON.stringify(nextNotifications), ); setNotifications(nextNotifications); window.location.reload(); }; return (
{/* Left: hamburger (mobile) + title */}
{onMobileMenuToggle && ( )}

{title}

{subtitle && (

{subtitle}

)}
{/* Right side */}
{/* Search */}
{ setQuery(event.target.value); setSearchOpen(true); }} onFocus={() => setSearchOpen(true)} onKeyDown={(event) => { if (event.key === "Enter" && filteredRoutes[0]) { event.preventDefault(); handleNavigate(filteredRoutes[0].href); } if (event.key === "Escape") { event.preventDefault(); setSearchOpen(false); searchRef.current?.blur(); } }} placeholder="Search pages..." style={{ background: "transparent", border: "none", outline: "none", color: "var(--text-primary)", fontSize: 13, width: "100%", }} /> {!query && (
{shortcutLabel.map((label) => ( {label} ))}
)} {searchOpen && (
{query ? `Showing ${filteredRoutes.length} result${filteredRoutes.length === 1 ? "" : "s"}` : "Quick navigation"}
{filteredRoutes.length > 0 ? ( filteredRoutes.map((item) => { const Icon = item.icon; const active = pathname === item.href; return ( ); }) ) : (
No matching pages.
)}
)}
{/* Live clock */}
{time}
{/* Connection status */} {/* Refresh */} {/* Notifications */}
{notificationOpen && (
Notifications
Latest {notifications.length} updates
{notifications.map((notification) => { const toneMeta = notificationToneMeta[notification.tone]; return (
{notification.title} {formatNotificationTimestamp( notification.timestamp, )}

{notification.message}

); })}
)}
); }