DashboardLayout.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. import { useAuth } from "@/_core/hooks/useAuth";
  2. import { Avatar, AvatarFallback } from "@/components/ui/avatar";
  3. import {
  4. DropdownMenu,
  5. DropdownMenuContent,
  6. DropdownMenuItem,
  7. DropdownMenuSeparator,
  8. DropdownMenuTrigger,
  9. } from "@/components/ui/dropdown-menu";
  10. import {
  11. Sidebar,
  12. SidebarContent,
  13. SidebarFooter,
  14. SidebarHeader,
  15. SidebarInset,
  16. SidebarMenu,
  17. SidebarMenuButton,
  18. SidebarMenuItem,
  19. SidebarProvider,
  20. SidebarTrigger,
  21. useSidebar,
  22. } from "@/components/ui/sidebar";
  23. import { getLoginUrl } from "@/const";
  24. import { useIsMobile } from "@/hooks/useMobile";
  25. import {
  26. Headphones, LogOut, PanelLeft, MessageSquare,
  27. GitBranch, Users, Home, Shield,
  28. Play, BarChart3, Database, Plug,
  29. } from "lucide-react";
  30. import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
  31. import { useLocation } from "wouter";
  32. import { DashboardLayoutSkeleton } from "./DashboardLayoutSkeleton";
  33. import { Button } from "./ui/button";
  34. /* ─── Role badge styling ─── */
  35. const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
  36. admin: { bg: "#14532D18", text: "#14532D", label: "Admin" },
  37. agent: { bg: "#0369a118", text: "#0369a1", label: "Agent" },
  38. user: { bg: "#78716C18", text: "#78716C", label: "User" },
  39. };
  40. /* ─── Menu items definition ─── */
  41. interface MenuItem {
  42. icon: React.ComponentType<{ className?: string }>;
  43. label: string;
  44. path: string;
  45. roles: string[]; // which roles can see this item
  46. }
  47. const ALL_MENU_ITEMS: MenuItem[] = [
  48. { icon: Home, label: "Home", path: "/", roles: ["admin", "agent", "user"] },
  49. { icon: MessageSquare, label: "Conversations", path: "/dashboard", roles: ["admin", "agent"] },
  50. { icon: Play, label: "Playground", path: "/playground", roles: ["admin", "agent"] },
  51. { icon: GitBranch, label: "Workflow Designer", path: "/workflow-designer", roles: ["admin"] },
  52. { icon: BarChart3, label: "Analytics", path: "/analytics", roles: ["admin"] },
  53. { icon: Database, label: "Data Sources", path: "/data-sources", roles: ["admin"] },
  54. { icon: Users, label: "User Management", path: "/dashboard/users", roles: ["admin"] },
  55. ];
  56. const SIDEBAR_WIDTH_KEY = "sidebar-width";
  57. const DEFAULT_WIDTH = 260;
  58. const MIN_WIDTH = 200;
  59. const MAX_WIDTH = 400;
  60. export default function DashboardLayout({
  61. children,
  62. requiredRole,
  63. }: {
  64. children: React.ReactNode;
  65. requiredRole?: "agent" | "admin";
  66. }) {
  67. const [sidebarWidth, setSidebarWidth] = useState(() => {
  68. const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
  69. return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
  70. });
  71. const { loading, user } = useAuth();
  72. useEffect(() => {
  73. localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
  74. }, [sidebarWidth]);
  75. if (loading) {
  76. return <DashboardLayoutSkeleton />;
  77. }
  78. if (!user) {
  79. // Redirect to login page with return URL
  80. const base = import.meta.env.BASE_URL ?? "/";
  81. const currentPath = window.location.pathname;
  82. window.location.href = `${base}login?returnTo=${encodeURIComponent(currentPath)}`;
  83. return <DashboardLayoutSkeleton />;
  84. }
  85. // Check role-based access
  86. if (requiredRole) {
  87. const hasAccess =
  88. requiredRole === "admin"
  89. ? user.role === "admin"
  90. : user.role === "admin" || user.role === "agent";
  91. if (!hasAccess) {
  92. return (
  93. <div
  94. className="flex items-center justify-center min-h-screen"
  95. style={{ background: "#FFFBEB" }}
  96. >
  97. <div className="flex flex-col items-center gap-6 p-8 max-w-md w-full rounded-2xl" style={{ background: "#fff", border: "1px solid #e7e0d5" }}>
  98. <div className="w-14 h-14 rounded-xl flex items-center justify-center" style={{ background: "#C2410C18", color: "#C2410C" }}>
  99. <Shield className="w-7 h-7" />
  100. </div>
  101. <div className="flex flex-col items-center gap-2">
  102. <h1
  103. className="text-xl font-bold text-center"
  104. style={{ fontFamily: "'Playfair Display', serif", color: "#292524" }}
  105. >
  106. Access Restricted
  107. </h1>
  108. <p
  109. className="text-sm text-center max-w-sm"
  110. style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}
  111. >
  112. {requiredRole === "admin"
  113. ? "This section requires admin privileges. Contact your administrator to request access."
  114. : "This section requires agent or admin privileges. Contact your administrator to request access."}
  115. </p>
  116. <div className="mt-2">
  117. <span
  118. className="text-xs font-medium px-3 py-1 rounded-full"
  119. style={{
  120. background: ROLE_STYLES[user.role]?.bg || ROLE_STYLES.user.bg,
  121. color: ROLE_STYLES[user.role]?.text || ROLE_STYLES.user.text,
  122. }}
  123. >
  124. Your role: {ROLE_STYLES[user.role]?.label || "User"}
  125. </span>
  126. </div>
  127. </div>
  128. <Button
  129. onClick={() => { window.location.href = import.meta.env.BASE_URL ?? "/"; }}
  130. variant="outline"
  131. className="w-full"
  132. style={{ borderColor: "#e7e0d5" }}
  133. >
  134. Back to Home
  135. </Button>
  136. </div>
  137. </div>
  138. );
  139. }
  140. }
  141. return (
  142. <SidebarProvider
  143. style={
  144. {
  145. "--sidebar-width": `${sidebarWidth}px`,
  146. } as CSSProperties
  147. }
  148. >
  149. <DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
  150. {children}
  151. </DashboardLayoutContent>
  152. </SidebarProvider>
  153. );
  154. }
  155. type DashboardLayoutContentProps = {
  156. children: React.ReactNode;
  157. setSidebarWidth: (width: number) => void;
  158. };
  159. function DashboardLayoutContent({
  160. children,
  161. setSidebarWidth,
  162. }: DashboardLayoutContentProps) {
  163. const { user, logout } = useAuth();
  164. const [location, setLocation] = useLocation();
  165. const { state, toggleSidebar } = useSidebar();
  166. const isCollapsed = state === "collapsed";
  167. const [isResizing, setIsResizing] = useState(false);
  168. const sidebarRef = useRef<HTMLDivElement>(null);
  169. const isMobile = useIsMobile();
  170. // Filter menu items based on user role
  171. const menuItems = useMemo(() => {
  172. if (!user) return [];
  173. return ALL_MENU_ITEMS.filter(item => item.roles.includes(user.role));
  174. }, [user]);
  175. const activeMenuItem = menuItems.find(item => item.path === location);
  176. const roleStyle = ROLE_STYLES[user?.role || "user"] || ROLE_STYLES.user;
  177. useEffect(() => {
  178. if (isCollapsed) {
  179. setIsResizing(false);
  180. }
  181. }, [isCollapsed]);
  182. useEffect(() => {
  183. const handleMouseMove = (e: MouseEvent) => {
  184. if (!isResizing) return;
  185. const sidebarLeft =
  186. sidebarRef.current?.getBoundingClientRect().left ?? 0;
  187. const newWidth = e.clientX - sidebarLeft;
  188. if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
  189. setSidebarWidth(newWidth);
  190. }
  191. };
  192. const handleMouseUp = () => {
  193. setIsResizing(false);
  194. };
  195. if (isResizing) {
  196. document.addEventListener("mousemove", handleMouseMove);
  197. document.addEventListener("mouseup", handleMouseUp);
  198. document.body.style.cursor = "col-resize";
  199. document.body.style.userSelect = "none";
  200. }
  201. return () => {
  202. document.removeEventListener("mousemove", handleMouseMove);
  203. document.removeEventListener("mouseup", handleMouseUp);
  204. document.body.style.cursor = "";
  205. document.body.style.userSelect = "";
  206. };
  207. }, [isResizing, setSidebarWidth]);
  208. return (
  209. <>
  210. <div className="relative" ref={sidebarRef}>
  211. <Sidebar
  212. collapsible="icon"
  213. className="border-r-0"
  214. disableTransition={isResizing}
  215. >
  216. <SidebarHeader className="h-16 justify-center">
  217. <div className="flex items-center gap-3 px-2 transition-all w-full">
  218. <button
  219. onClick={toggleSidebar}
  220. className="h-8 w-8 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
  221. style={{ background: "#14532D", color: "#fff" }}
  222. aria-label="Toggle navigation"
  223. >
  224. <PanelLeft className="h-4 w-4" />
  225. </button>
  226. {!isCollapsed ? (
  227. <div className="flex items-center gap-2 min-w-0">
  228. <span
  229. className="font-bold tracking-tight truncate text-sm"
  230. style={{ fontFamily: "'Playfair Display', serif", color: "#14532D" }}
  231. >
  232. Homelegance
  233. </span>
  234. </div>
  235. ) : null}
  236. </div>
  237. </SidebarHeader>
  238. <SidebarContent className="gap-0">
  239. <SidebarMenu className="px-2 py-1">
  240. {menuItems.map(item => {
  241. const isActive = location === item.path;
  242. return (
  243. <SidebarMenuItem key={item.path}>
  244. <SidebarMenuButton
  245. isActive={isActive}
  246. onClick={() => setLocation(item.path)}
  247. tooltip={item.label}
  248. className="h-10 transition-all font-normal"
  249. >
  250. <item.icon
  251. className={`h-4 w-4`}
  252. />
  253. <span
  254. className="text-sm"
  255. style={{
  256. fontFamily: "'Source Sans 3', sans-serif",
  257. color: isActive ? "#14532D" : "#57534e",
  258. fontWeight: isActive ? 600 : 400,
  259. }}
  260. >
  261. {item.label}
  262. </span>
  263. </SidebarMenuButton>
  264. </SidebarMenuItem>
  265. );
  266. })}
  267. </SidebarMenu>
  268. </SidebarContent>
  269. <SidebarFooter className="p-3">
  270. <DropdownMenu>
  271. <DropdownMenuTrigger asChild>
  272. <button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
  273. <Avatar className="h-9 w-9 border shrink-0">
  274. <AvatarFallback
  275. className="text-xs font-medium"
  276. style={{ background: roleStyle.bg, color: roleStyle.text }}
  277. >
  278. {user?.name?.charAt(0).toUpperCase() || "?"}
  279. </AvatarFallback>
  280. </Avatar>
  281. <div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
  282. <div className="flex items-center gap-2">
  283. <p className="text-sm font-medium truncate leading-none">
  284. {user?.name || "-"}
  285. </p>
  286. <span
  287. className="text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0"
  288. style={{ background: roleStyle.bg, color: roleStyle.text }}
  289. >
  290. {roleStyle.label}
  291. </span>
  292. </div>
  293. <p className="text-xs text-muted-foreground truncate mt-1">
  294. {user?.email || "-"}
  295. </p>
  296. </div>
  297. </button>
  298. </DropdownMenuTrigger>
  299. <DropdownMenuContent align="end" className="w-52">
  300. <div className="px-2 py-1.5">
  301. <p className="text-xs text-muted-foreground">Signed in as</p>
  302. <p className="text-sm font-medium truncate">{user?.name || user?.email}</p>
  303. <span
  304. className="text-[10px] font-medium px-1.5 py-0.5 rounded-full inline-block mt-1"
  305. style={{ background: roleStyle.bg, color: roleStyle.text }}
  306. >
  307. {roleStyle.label}
  308. </span>
  309. </div>
  310. <DropdownMenuSeparator />
  311. <DropdownMenuItem
  312. onClick={logout}
  313. className="cursor-pointer text-destructive focus:text-destructive"
  314. >
  315. <LogOut className="mr-2 h-4 w-4" />
  316. <span>Sign out</span>
  317. </DropdownMenuItem>
  318. </DropdownMenuContent>
  319. </DropdownMenu>
  320. </SidebarFooter>
  321. </Sidebar>
  322. <div
  323. className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
  324. onMouseDown={() => {
  325. if (isCollapsed) return;
  326. setIsResizing(true);
  327. }}
  328. style={{ zIndex: 50 }}
  329. />
  330. </div>
  331. <SidebarInset>
  332. {isMobile && (
  333. <div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
  334. <div className="flex items-center gap-2">
  335. <SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
  336. <div className="flex items-center gap-3">
  337. <div className="flex flex-col gap-1">
  338. <span
  339. className="tracking-tight text-foreground text-sm"
  340. style={{ fontFamily: "'Source Sans 3', sans-serif" }}
  341. >
  342. {activeMenuItem?.label ?? "Dashboard"}
  343. </span>
  344. </div>
  345. </div>
  346. </div>
  347. </div>
  348. )}
  349. <main className="flex-1 p-4" style={{ background: "#FFFBEB" }}>
  350. {children}
  351. </main>
  352. </SidebarInset>
  353. </>
  354. );
  355. }