Files
doktainer-upstream/src/components/Topbar.tsx
T
2026-06-02 00:17:50 +07:00

1095 lines
31 KiB
TypeScript

"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<ConnectionState>("checking");
const [query, setQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark");
const [notifications, setNotifications] = useState<TopbarNotification[]>([]);
const [notificationOpen, setNotificationOpen] = useState(false);
const searchRef = useRef<HTMLInputElement | null>(null);
const searchShellRef = useRef<HTMLDivElement | null>(null);
const notificationShellRef = useRef<HTMLDivElement | null>(null);
const notificationsRef = useRef<TopbarNotification[]>([]);
const lastConnectionNotificationRef = useRef<ConnectionState | null>(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<TopbarNotification, "id" | "timestamp" | "read">) => {
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: <LoaderCircle size={13} className="animate-spin" />,
},
online: {
label: "Browser online and API reachable",
color: "#10b981",
background: "rgba(16, 185, 129, 0.1)",
border: "rgba(16, 185, 129, 0.3)",
icon: <Wifi size={13} />,
},
offline: {
label: "Browser offline. Check your network.",
color: "#ef4444",
background: "rgba(239, 68, 68, 0.1)",
border: "rgba(239, 68, 68, 0.3)",
icon: <WifiOff size={13} />,
},
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: <WifiOff size={13} />,
},
}[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 (
<header
style={{
height: 64,
background: "var(--bg-secondary)",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 16px",
gap: 12,
flexShrink: 0,
}}
className="md:px-6 md:gap-4"
>
{/* Left: hamburger (mobile) + title */}
<div
style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}
>
{onMobileMenuToggle && (
<button
onClick={onMobileMenuToggle}
style={{
background: "transparent",
border: "none",
color: "var(--text-secondary)",
cursor: "pointer",
padding: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 6,
}}
aria-label="Open menu"
>
<Menu size={20} />
</button>
)}
<div style={{ minWidth: 0, overflow: "hidden", whiteSpace: "nowrap" }}>
<h1
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1.2,
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{title}
</h1>
{subtitle && (
<p
className="hidden md:block"
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 1,
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{subtitle}
</p>
)}
</div>
</div>
{/* Right side */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexShrink: 0,
}}
>
{/* Search */}
<div
ref={searchShellRef}
className="hidden sm:flex"
style={{
position: "relative",
alignItems: "center",
gap: 8,
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "6px 10px",
width: 240,
}}
>
<Search
size={13}
style={{ color: "var(--text-muted)", flexShrink: 0 }}
/>
<input
ref={searchRef}
type="text"
name="panel-route-search"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
value={query}
onChange={(event) => {
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 && (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{shortcutLabel.map((label) => (
<kbd
key={label}
style={{
minWidth: 22,
height: 18,
padding: "0 6px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-secondary)",
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}}
>
{label}
</kbd>
))}
</div>
)}
{searchOpen && (
<div
className="card animate-slide-in"
style={{
position: "absolute",
top: "calc(100% + 8px)",
left: 0,
right: 0,
zIndex: 100,
overflow: "hidden",
boxShadow: "0 16px 32px rgba(2, 6, 23, 0.26)",
}}
>
<div
style={{
padding: "10px 12px",
borderBottom: "1px solid var(--border)",
fontSize: 11,
color: "var(--text-muted)",
}}
>
{query
? `Showing ${filteredRoutes.length} result${filteredRoutes.length === 1 ? "" : "s"}`
: "Quick navigation"}
</div>
{filteredRoutes.length > 0 ? (
filteredRoutes.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
return (
<button
key={item.href}
type="button"
onClick={() => handleNavigate(item.href)}
style={{
width: "100%",
border: "none",
background: active
? "rgba(59, 130, 246, 0.08)"
: "transparent",
color: "inherit",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderBottom: "1px solid rgba(30, 42, 61, 0.35)",
textAlign: "left",
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: "rgba(59, 130, 246, 0.12)",
color: "#3b82f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Icon size={14} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.label}
</div>
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.sectionLabel} · {item.href}
</div>
</div>
</button>
);
})
) : (
<div
style={{
padding: "14px 12px",
fontSize: 12,
color: "var(--text-muted)",
}}
>
No matching pages.
</div>
)}
</div>
)}
</div>
{/* Live clock */}
<div
className="hidden sm:flex"
style={{
alignItems: "center",
gap: 6,
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "6px 12px",
}}
>
<div
className="animate-pulse-dot"
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "#10b981",
}}
/>
<span
style={{
fontSize: 12,
fontFamily: "JetBrains Mono, monospace",
color: "var(--text-secondary)",
}}
>
{time}
</span>
</div>
<button
onClick={handleThemeToggle}
className="tooltip"
style={{
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "6px 10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color:
resolvedTheme === "light" ? "#f59e0b" : "var(--text-secondary)",
}}
aria-label={
resolvedTheme === "light"
? "Switch to dark mode"
: "Switch to light mode"
}
>
{resolvedTheme === "light" ? (
<Moon size={14} />
) : (
<SunMedium size={14} />
)}
<span className="tooltip-content">
{resolvedTheme === "light"
? "Switch to dark mode"
: "Switch to light mode"}
</span>
</button>
{/* Connection status */}
<button
onClick={() => void checkConnection()}
className="tooltip hidden md:flex md:items-center"
style={{
background: connectionMeta.background,
border: `1px solid ${connectionMeta.border}`,
borderRadius: 8,
padding: "6px 10px",
gap: 6,
cursor: "pointer",
color: connectionMeta.color,
fontSize: 12,
fontWeight: 500,
}}
aria-label="Re-check connection status"
>
{connectionMeta.icon}
<span className="tooltip-content">{connectionMeta.label}</span>
</button>
{/* Refresh */}
<button
className="btn btn-ghost hidden md:flex"
style={{ padding: "6px 10px" }}
title="Refresh"
onClick={handleRefresh}
>
<RefreshCw size={13} />
</button>
{/* Notifications */}
<div ref={notificationShellRef} style={{ position: "relative" }}>
<button
type="button"
onClick={() => {
setSearchOpen(false);
setNotificationOpen((current) => !current);
}}
style={{
position: "relative",
background: notificationOpen
? "rgba(59, 130, 246, 0.08)"
: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "6px 10px",
cursor: "pointer",
color: notificationOpen
? "var(--text-primary)"
: "var(--text-secondary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
aria-label="Open notifications"
>
<Bell size={15} />
{unreadCount > 0 && (
<span
style={{
position: "absolute",
top: -4,
right: -4,
minWidth: 16,
height: 16,
borderRadius: 999,
background: "#ef4444",
border: "2px solid var(--bg-secondary)",
color: "#ffffff",
fontSize: 9,
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0 4px",
lineHeight: 1,
}}
>
{Math.min(unreadCount, 9)}
</span>
)}
</button>
{notificationOpen && (
<div
className="card animate-slide-in"
style={{
position: "absolute",
top: "calc(100% + 10px)",
right: 0,
width: 320,
maxWidth: "calc(100vw - 32px)",
zIndex: 110,
overflow: "hidden",
boxShadow: "0 16px 32px rgba(2, 6, 23, 0.22)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 14px",
borderBottom: "1px solid var(--border)",
}}
>
<div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Notifications
</div>
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
}}
>
Latest {notifications.length} updates
</div>
</div>
<button
type="button"
onClick={markNotificationsRead}
style={{
border: "none",
background: "transparent",
color: "#3b82f6",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
}}
>
Mark all read
</button>
</div>
<div style={{ maxHeight: 360, overflowY: "auto" }}>
{notifications.map((notification) => {
const toneMeta = notificationToneMeta[notification.tone];
return (
<div
key={notification.id}
style={{
padding: "12px 14px",
borderBottom: "1px solid rgba(30, 42, 61, 0.18)",
background: notification.read
? "transparent"
: toneMeta.background,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: toneMeta.dot,
marginTop: 4,
flexShrink: 0,
boxShadow: `0 0 0 4px ${toneMeta.border}`,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 3,
}}
>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{notification.title}
</span>
<span
style={{
fontSize: 10,
color: "var(--text-muted)",
whiteSpace: "nowrap",
}}
>
{formatNotificationTimestamp(
notification.timestamp,
)}
</span>
</div>
<p
style={{
fontSize: 11,
color: "var(--text-secondary)",
lineHeight: 1.5,
}}
>
{notification.message}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</header>
);
}