| import { useAuth } from "@/_core/hooks/useAuth"; |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| 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 { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; |
| import { CSSProperties, useEffect, useRef, useState } from "react"; |
| import { useLocation } from "wouter"; |
| import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; |
| import { Button } from "./ui/button"; |
|
|
| const menuItems = [ |
| { icon: LayoutDashboard, label: "Page 1", path: "/" }, |
| { icon: Users, label: "Page 2", path: "/some-path" }, |
| ]; |
|
|
| const SIDEBAR_WIDTH_KEY = "sidebar-width"; |
| const DEFAULT_WIDTH = 280; |
| const MIN_WIDTH = 200; |
| const MAX_WIDTH = 480; |
|
|
| export default function DashboardLayout({ |
| children, |
| }: { |
| children: React.ReactNode; |
| }) { |
| 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) { |
| return ( |
| <div className="flex items-center justify-center min-h-screen"> |
| <div className="flex flex-col items-center gap-8 p-8 max-w-md w-full"> |
| <div className="flex flex-col items-center gap-6"> |
| <h1 className="text-2xl font-semibold tracking-tight text-center"> |
| Sign in to continue |
| </h1> |
| <p className="text-sm text-muted-foreground text-center max-w-sm"> |
| Access to this dashboard requires authentication. Continue to launch the login flow. |
| </p> |
| </div> |
| <Button |
| onClick={() => { |
| window.location.href = getLoginUrl(); |
| }} |
| size="lg" |
| className="w-full shadow-lg hover:shadow-xl transition-all" |
| > |
| Sign in |
| </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 activeMenuItem = menuItems.find(item => item.path === location); |
| const isMobile = useIsMobile(); |
|
|
| 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 hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0" |
| aria-label="Toggle navigation" |
| > |
| <PanelLeft className="h-4 w-4 text-muted-foreground" /> |
| </button> |
| {!isCollapsed ? ( |
| <div className="flex items-center gap-2 min-w-0"> |
| <span className="font-semibold tracking-tight truncate"> |
| Navigation |
| </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 ${isActive ? "text-primary" : ""}`} |
| /> |
| <span>{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"> |
| {user?.name?.charAt(0).toUpperCase()} |
| </AvatarFallback> |
| </Avatar> |
| <div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden"> |
| <p className="text-sm font-medium truncate leading-none"> |
| {user?.name || "-"} |
| </p> |
| <p className="text-xs text-muted-foreground truncate mt-1.5"> |
| {user?.email || "-"} |
| </p> |
| </div> |
| </button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent align="end" className="w-48"> |
| <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"> |
| {activeMenuItem?.label ?? "Menu"} |
| </span> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| <main className="flex-1 p-4">{children}</main> |
| </SidebarInset> |
| </> |
| ); |
| } |
|
|