| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- import { useAuth } from "@/_core/hooks/useAuth";
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
- import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- } from "@/components/ui/dropdown-menu";
- import {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarHeader,
- SidebarInset,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarProvider,
- SidebarTrigger,
- useSidebar,
- } from "@/components/ui/sidebar";
- import { getLoginUrl } from "@/const";
- import { useIsMobile } from "@/hooks/useMobile";
- import {
- Headphones, LogOut, PanelLeft, MessageSquare,
- GitBranch, Users, Home, Shield,
- Play, BarChart3, Database, Plug,
- } from "lucide-react";
- import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
- import { useLocation } from "wouter";
- import { DashboardLayoutSkeleton } from "./DashboardLayoutSkeleton";
- import { Button } from "./ui/button";
- /* ─── Role badge styling ─── */
- const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
- admin: { bg: "#14532D18", text: "#14532D", label: "Admin" },
- agent: { bg: "#0369a118", text: "#0369a1", label: "Agent" },
- user: { bg: "#78716C18", text: "#78716C", label: "User" },
- };
- /* ─── Menu items definition ─── */
- interface MenuItem {
- icon: React.ComponentType<{ className?: string }>;
- label: string;
- path: string;
- roles: string[]; // which roles can see this item
- }
- const ALL_MENU_ITEMS: MenuItem[] = [
- { icon: Home, label: "Home", path: "/", roles: ["admin", "agent", "user"] },
- { icon: MessageSquare, label: "Conversations", path: "/dashboard", roles: ["admin", "agent"] },
- { icon: Play, label: "Playground", path: "/playground", roles: ["admin", "agent"] },
- { icon: GitBranch, label: "Workflow Designer", path: "/workflow-designer", roles: ["admin"] },
- { icon: BarChart3, label: "Analytics", path: "/analytics", roles: ["admin"] },
- { icon: Database, label: "Data Sources", path: "/data-sources", roles: ["admin"] },
- { icon: Users, label: "User Management", path: "/dashboard/users", roles: ["admin"] },
- ];
- const SIDEBAR_WIDTH_KEY = "sidebar-width";
- const DEFAULT_WIDTH = 260;
- const MIN_WIDTH = 200;
- const MAX_WIDTH = 400;
- export default function DashboardLayout({
- children,
- requiredRole,
- }: {
- children: React.ReactNode;
- requiredRole?: "agent" | "admin";
- }) {
- const [sidebarWidth, setSidebarWidth] = useState(() => {
- const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
- return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
- });
- const { loading, user } = useAuth();
- useEffect(() => {
- localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
- }, [sidebarWidth]);
- if (loading) {
- return <DashboardLayoutSkeleton />;
- }
- if (!user) {
- // Redirect to login page with return URL
- const base = import.meta.env.BASE_URL ?? "/";
- const currentPath = window.location.pathname;
- window.location.href = `${base}login?returnTo=${encodeURIComponent(currentPath)}`;
- return <DashboardLayoutSkeleton />;
- }
- // Check role-based access
- if (requiredRole) {
- const hasAccess =
- requiredRole === "admin"
- ? user.role === "admin"
- : user.role === "admin" || user.role === "agent";
- if (!hasAccess) {
- return (
- <div
- className="flex items-center justify-center min-h-screen"
- style={{ background: "#FFFBEB" }}
- >
- <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" }}>
- <div className="w-14 h-14 rounded-xl flex items-center justify-center" style={{ background: "#C2410C18", color: "#C2410C" }}>
- <Shield className="w-7 h-7" />
- </div>
- <div className="flex flex-col items-center gap-2">
- <h1
- className="text-xl font-bold text-center"
- style={{ fontFamily: "'Playfair Display', serif", color: "#292524" }}
- >
- Access Restricted
- </h1>
- <p
- className="text-sm text-center max-w-sm"
- style={{ color: "#78716C", fontFamily: "'Source Sans 3', sans-serif" }}
- >
- {requiredRole === "admin"
- ? "This section requires admin privileges. Contact your administrator to request access."
- : "This section requires agent or admin privileges. Contact your administrator to request access."}
- </p>
- <div className="mt-2">
- <span
- className="text-xs font-medium px-3 py-1 rounded-full"
- style={{
- background: ROLE_STYLES[user.role]?.bg || ROLE_STYLES.user.bg,
- color: ROLE_STYLES[user.role]?.text || ROLE_STYLES.user.text,
- }}
- >
- Your role: {ROLE_STYLES[user.role]?.label || "User"}
- </span>
- </div>
- </div>
- <Button
- onClick={() => { window.location.href = import.meta.env.BASE_URL ?? "/"; }}
- variant="outline"
- className="w-full"
- style={{ borderColor: "#e7e0d5" }}
- >
- Back to Home
- </Button>
- </div>
- </div>
- );
- }
- }
- return (
- <SidebarProvider
- style={
- {
- "--sidebar-width": `${sidebarWidth}px`,
- } as CSSProperties
- }
- >
- <DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
- {children}
- </DashboardLayoutContent>
- </SidebarProvider>
- );
- }
- type DashboardLayoutContentProps = {
- children: React.ReactNode;
- setSidebarWidth: (width: number) => void;
- };
- function DashboardLayoutContent({
- children,
- setSidebarWidth,
- }: DashboardLayoutContentProps) {
- const { user, logout } = useAuth();
- const [location, setLocation] = useLocation();
- const { state, toggleSidebar } = useSidebar();
- const isCollapsed = state === "collapsed";
- const [isResizing, setIsResizing] = useState(false);
- const sidebarRef = useRef<HTMLDivElement>(null);
- const isMobile = useIsMobile();
- // Filter menu items based on user role
- const menuItems = useMemo(() => {
- if (!user) return [];
- return ALL_MENU_ITEMS.filter(item => item.roles.includes(user.role));
- }, [user]);
- const activeMenuItem = menuItems.find(item => item.path === location);
- const roleStyle = ROLE_STYLES[user?.role || "user"] || ROLE_STYLES.user;
- useEffect(() => {
- if (isCollapsed) {
- setIsResizing(false);
- }
- }, [isCollapsed]);
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (!isResizing) return;
- const sidebarLeft =
- sidebarRef.current?.getBoundingClientRect().left ?? 0;
- const newWidth = e.clientX - sidebarLeft;
- if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
- setSidebarWidth(newWidth);
- }
- };
- const handleMouseUp = () => {
- setIsResizing(false);
- };
- if (isResizing) {
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- document.body.style.cursor = "col-resize";
- document.body.style.userSelect = "none";
- }
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
- };
- }, [isResizing, setSidebarWidth]);
- return (
- <>
- <div className="relative" ref={sidebarRef}>
- <Sidebar
- collapsible="icon"
- className="border-r-0"
- disableTransition={isResizing}
- >
- <SidebarHeader className="h-16 justify-center">
- <div className="flex items-center gap-3 px-2 transition-all w-full">
- <button
- onClick={toggleSidebar}
- 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"
- style={{ background: "#14532D", color: "#fff" }}
- aria-label="Toggle navigation"
- >
- <PanelLeft className="h-4 w-4" />
- </button>
- {!isCollapsed ? (
- <div className="flex items-center gap-2 min-w-0">
- <span
- className="font-bold tracking-tight truncate text-sm"
- style={{ fontFamily: "'Playfair Display', serif", color: "#14532D" }}
- >
- Homelegance
- </span>
- </div>
- ) : null}
- </div>
- </SidebarHeader>
- <SidebarContent className="gap-0">
- <SidebarMenu className="px-2 py-1">
- {menuItems.map(item => {
- const isActive = location === item.path;
- return (
- <SidebarMenuItem key={item.path}>
- <SidebarMenuButton
- isActive={isActive}
- onClick={() => setLocation(item.path)}
- tooltip={item.label}
- className="h-10 transition-all font-normal"
- >
- <item.icon
- className={`h-4 w-4`}
- />
- <span
- className="text-sm"
- style={{
- fontFamily: "'Source Sans 3', sans-serif",
- color: isActive ? "#14532D" : "#57534e",
- fontWeight: isActive ? 600 : 400,
- }}
- >
- {item.label}
- </span>
- </SidebarMenuButton>
- </SidebarMenuItem>
- );
- })}
- </SidebarMenu>
- </SidebarContent>
- <SidebarFooter className="p-3">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <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">
- <Avatar className="h-9 w-9 border shrink-0">
- <AvatarFallback
- className="text-xs font-medium"
- style={{ background: roleStyle.bg, color: roleStyle.text }}
- >
- {user?.name?.charAt(0).toUpperCase() || "?"}
- </AvatarFallback>
- </Avatar>
- <div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
- <div className="flex items-center gap-2">
- <p className="text-sm font-medium truncate leading-none">
- {user?.name || "-"}
- </p>
- <span
- className="text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0"
- style={{ background: roleStyle.bg, color: roleStyle.text }}
- >
- {roleStyle.label}
- </span>
- </div>
- <p className="text-xs text-muted-foreground truncate mt-1">
- {user?.email || "-"}
- </p>
- </div>
- </button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-52">
- <div className="px-2 py-1.5">
- <p className="text-xs text-muted-foreground">Signed in as</p>
- <p className="text-sm font-medium truncate">{user?.name || user?.email}</p>
- <span
- className="text-[10px] font-medium px-1.5 py-0.5 rounded-full inline-block mt-1"
- style={{ background: roleStyle.bg, color: roleStyle.text }}
- >
- {roleStyle.label}
- </span>
- </div>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={logout}
- className="cursor-pointer text-destructive focus:text-destructive"
- >
- <LogOut className="mr-2 h-4 w-4" />
- <span>Sign out</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </SidebarFooter>
- </Sidebar>
- <div
- className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
- onMouseDown={() => {
- if (isCollapsed) return;
- setIsResizing(true);
- }}
- style={{ zIndex: 50 }}
- />
- </div>
- <SidebarInset>
- {isMobile && (
- <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">
- <div className="flex items-center gap-2">
- <SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
- <div className="flex items-center gap-3">
- <div className="flex flex-col gap-1">
- <span
- className="tracking-tight text-foreground text-sm"
- style={{ fontFamily: "'Source Sans 3', sans-serif" }}
- >
- {activeMenuItem?.label ?? "Dashboard"}
- </span>
- </div>
- </div>
- </div>
- </div>
- )}
- <main className="flex-1 p-4" style={{ background: "#FFFBEB" }}>
- {children}
- </main>
- </SidebarInset>
- </>
- );
- }
|