Files
Termix/src/ui/sidebar/AppRail.tsx
T

419 lines
13 KiB
TypeScript

import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Bell,
Clock,
Fingerprint,
Hammer,
KeyRound,
LayoutPanelLeft,
LogOut,
Network,
Play,
Plug,
ScrollText,
Server,
Settings,
Usb,
User,
Zap,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
export type RailView =
| "hosts"
| "credentials"
| "termix-id"
| "quick-connect"
| "serial"
| ToolsTab
| "connections"
| "session-logs"
| "user-profile"
| "admin-settings"
| "alerts";
export type HideableRailView =
| Exclude<RailView, "user-profile" | "admin-settings">
| "network_graph"
| "homepage";
type RailItem =
| {
kind?: undefined;
view: RailView;
icon: React.ReactNode;
title: string;
dot?: boolean;
}
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" };
function buildRailButtons(
splitMode: SplitMode,
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
{
view: "credentials",
icon: <KeyRound size={16} />,
title: t("nav.credentials"),
},
{ kind: "separator" },
{
view: "termix-id",
icon: <Fingerprint size={16} />,
title: t("nav.termixId"),
},
{ kind: "separator" },
{
view: "connections",
icon: <Plug size={16} />,
title: t("nav.connections"),
},
{ kind: "separator" },
{
view: "quick-connect",
icon: <Zap size={16} />,
title: t("nav.quickConnect"),
},
{ kind: "separator" },
{
view: "serial",
icon: <Usb size={16} />,
title: t("nav.serial"),
},
{ kind: "separator" },
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
{ kind: "separator" },
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
{ kind: "separator" },
{ view: "history", icon: <Clock size={16} />, title: t("nav.history") },
{ kind: "separator" },
{
view: "session-logs",
icon: <ScrollText size={16} />,
title: t("nav.sessionLogs"),
},
{ kind: "separator" },
{
view: "split-screen",
icon: <LayoutPanelLeft size={16} />,
title: t("nav.splitScreen"),
dot: splitMode !== "none",
},
{ kind: "separator" },
{
kind: "tab",
tabType: "network_graph" as TabType,
icon: <Network size={16} />,
title: t("nav.networkGraph"),
},
{ kind: "separator" },
];
// Filter out hidden items, then collapse consecutive/leading/trailing separators
const filtered = all.filter((item) => {
if (item.kind === "separator") return true;
if (item.kind === "tab") return !hidden.has(item.tabType);
return !hidden.has(item.view);
});
const result: RailItem[] = [];
for (const item of filtered) {
if (item.kind === "separator") {
if (result.length === 0 || result[result.length - 1].kind === "separator")
continue;
result.push(item);
} else {
result.push(item);
}
}
if (result[result.length - 1]?.kind === "separator") result.pop();
return result;
}
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: "0 4px", padding: "0 8px" };
export function AppRail({
railView,
sidebarOpen,
splitMode,
username,
isAdmin,
onRailClick,
onOpenTab,
onLogout,
}: {
railView: RailView;
sidebarOpen: boolean;
splitMode: SplitMode;
username: string;
isAdmin: boolean;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(
() => localStorage.getItem("pinAppRail") === "true",
);
const [expandOnHover, setExpandOnHover] = useState(
() => localStorage.getItem("expandAppRailOnHover") !== "false",
);
const [unreadAlerts, setUnreadAlerts] = useState(0);
useEffect(() => {
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = () => {
if (document.visibilityState === "hidden") return;
getAlertFirings({ acknowledged: false, limit: 50 })
.then((firings) => {
if (!cancelled) setUnreadAlerts(firings.length);
})
.catch(() => {});
};
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(poll, 30000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
poll();
start();
};
poll();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
useEffect(() => {
const pinHandler = () =>
setPinned(localStorage.getItem("pinAppRail") === "true");
const hoverHandler = () =>
setExpandOnHover(
localStorage.getItem("expandAppRailOnHover") !== "false",
);
window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
return () => {
window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
};
}, []);
useEffect(() => {
const handler = () => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
} catch {
setHiddenTabs(new Set());
}
};
window.addEventListener("hiddenRailTabsChanged", handler);
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: railExpanded ? 160 : 40 }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
) : item.kind === "tab" ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={() => onRailClick(item.view)}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
{item.dot && (
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-accent-brand" />
)}
</button>
),
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
{[
{
view: "alerts" as RailView,
icon: <Bell size={16} />,
title: t("nav.alerts"),
},
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
].map((item) => (
<button
key={item.view}
onClick={() => onRailClick(item.view)}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
{item.view === "alerts" && unreadAlerts > 0 && (
<span className="absolute -top-1 -right-1 flex size-3 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-white leading-none">
{unreadAlerts > 9 ? "9+" : unreadAlerts}
</span>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{item.title}
</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border" />
<button
onClick={onLogout}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
<LogOut size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{t("common.logout")}
</span>
</button>
</div>
<div className="shrink-0 border-t border-border">
<button
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 8px" }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: 24, height: 24, fontSize: 11 }}
>
{username.charAt(0).toUpperCase() || "U"}
</div>
<div
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
</span>
</div>
</button>
</div>
</div>
);
}