From f82cd253cade1d47683d207b343ca19dede51f73 Mon Sep 17 00:00:00 2001 From: BUDUMURU SRINIVAS SAI SARAN TEJA Date: Fri, 2 Jan 2026 02:20:20 +0530 Subject: [PATCH] feat: add collapsible, dropdown menu, separator, sidebar, skeleton, tooltip components and mobile hook - Implemented `Collapsible`, `CollapsibleTrigger`, and `CollapsibleContent` components for collapsible UI sections. - Created `DropdownMenu` components including `DropdownMenuPortal`, `DropdownMenuTrigger`, `DropdownMenuContent`, and various item types for enhanced dropdown functionality. - Added `Separator` component for visual separation in layouts. - Developed a comprehensive `Sidebar` component with context management, mobile responsiveness, and various subcomponents for structured navigation. - Introduced `Skeleton` component for loading states. - Implemented `Tooltip` components for contextual hints and information. - Added `useIsMobile` hook to detect mobile device state for responsive design. --- .../src/routes/userRoutes/userRoutes.ts | 33 +- .../app/components/nodes/CreateWorkFlow.tsx | 9 +- .../nodes/GoogleSheetFormClient.tsx | 5 +- apps/web/app/components/providers.tsx | 4 +- apps/web/app/components/ui/app-sidebar.tsx | 216 ++++++ apps/web/app/workflow/lib/config.ts | 34 +- apps/web/app/workflow/page.tsx | 18 +- apps/web/store/slices/userSlice.ts | 14 +- packages/common/src/index.ts | 1 - packages/ui/package.json | 4 + packages/ui/src/components/button.tsx | 15 +- packages/ui/src/components/collapsible.tsx | 33 + packages/ui/src/components/dropdown-menu.tsx | 257 +++++++ packages/ui/src/components/separator.tsx | 28 + packages/ui/src/components/sidebar.tsx | 726 ++++++++++++++++++ packages/ui/src/components/skeleton.tsx | 13 + packages/ui/src/components/tooltip.tsx | 61 ++ packages/ui/src/hooks/use-mobile.ts | 19 + pnpm-lock.yaml | 193 +++++ 19 files changed, 1661 insertions(+), 22 deletions(-) create mode 100644 apps/web/app/components/ui/app-sidebar.tsx create mode 100644 packages/ui/src/components/collapsible.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/separator.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/components/skeleton.tsx create mode 100644 packages/ui/src/components/tooltip.tsx create mode 100644 packages/ui/src/hooks/use-mobile.ts diff --git a/apps/http-backend/src/routes/userRoutes/userRoutes.ts b/apps/http-backend/src/routes/userRoutes/userRoutes.ts index 6cf40f5..ab1d441 100644 --- a/apps/http-backend/src/routes/userRoutes/userRoutes.ts +++ b/apps/http-backend/src/routes/userRoutes/userRoutes.ts @@ -200,6 +200,31 @@ router.get('/getCredentials/:type', } ); +router.get('/getAllCreds', userMiddleware, async(req: AuthRequest, res:Response) =>{ + try{ + if(!req.user){ + return res.status(statusCodes.BAD_REQUEST).json({ + message: "User is not Loggedin" + }) + } + const userId = req.user.sub; + const creds = await prismaClient.credential.findMany({ + where:{ userId: userId} + }) + if(creds){ + return res.status(statusCodes.OK).json({ + message: "Fetched all credentials of the User!", + data: creds + }) + } + } + catch(e){ + console.log("Error Fetching the credentials ", e instanceof Error ? e.message : "Unkown reason"); + return res + .status(statusCodes.INTERNAL_SERVER_ERROR) + .json({ message: "Internal server from fetching the credentials" }); + } +}) // ----------------------------------- CREATE WORKFLOW --------------------------------- router.post("/create/workflow", @@ -341,7 +366,7 @@ router.get("/workflow/:workflowId", Data: getWorkflow, }); } catch (error: any) { - console.log("Error Fetching the workflow ", error.meesage); + console.log("Error Fetching the workflow ", error.message); return res .status(statusCodes.INTERNAL_SERVER_ERROR) .json({ message: "Internal server from fetching the workflow" }); @@ -373,6 +398,12 @@ router.post('/create/trigger', userMiddleware, async(req: AuthRequest, res: Resp // trigger type pettla db lo ledu aa column } }) + await prismaClient.workflow.update({ + where:{ id: dataSafe.data.WorkflowId }, + data:{ + isEmpty: false + } + }) if(createdTrigger){ return res.status(statusCodes.CREATED).json({ diff --git a/apps/web/app/components/nodes/CreateWorkFlow.tsx b/apps/web/app/components/nodes/CreateWorkFlow.tsx index 7cda789..619ac7f 100644 --- a/apps/web/app/components/nodes/CreateWorkFlow.tsx +++ b/apps/web/app/components/nodes/CreateWorkFlow.tsx @@ -131,7 +131,7 @@ export const CreateWorkFlow = () => { } else{ if (!userId) return - const newWorkflow = await createWorkflow(userId) + const newWorkflow = await createWorkflow() dispatch(workflowActions.setWorkflowId(newWorkflow.id)) dispatch(workflowActions.setWorkflowStatus(newWorkflow.isEmpty)) } @@ -146,7 +146,7 @@ export const CreateWorkFlow = () => { // console.log(`workfklow from redux: ${workflow.data}`) } } - if(!workflowId) getEmptyWorkflowID() + // if(!workflowId) getEmptyWorkflowID() getWorkflowData(); },[dispatch, userId, workflowId]) @@ -292,8 +292,8 @@ export const CreateWorkFlow = () => { }; return ( -
- + { (initialData?.credentialId || ''); const [documents, setDocuments] = useState>([]); const [selectedDocument, setSelectedDocument] = useState(initialData?.spreadSheetId || ''); @@ -190,6 +192,7 @@ export function GoogleSheetFormClient({ type, nodeType, initialData }: GoogleShe node_Trigger:nodeId, workflowId, type:nodeTypeParsed, + position: position, name: `Google sheet - ${nodeTypeParsed}`, credentialId: selectedCredential, spreadsheetId: selectedDocument, diff --git a/apps/web/app/components/providers.tsx b/apps/web/app/components/providers.tsx index 2a8b25c..0d5f560 100644 --- a/apps/web/app/components/providers.tsx +++ b/apps/web/app/components/providers.tsx @@ -16,8 +16,10 @@ function SessionSync(){ if(status === 'authenticated'){ dispatch(userAction.setUserStatus('Authenticated')); const id = (data?.user as any)?.id ?? null; - console.log("user from Session Sync ", id) + // console.log("user from Session Sync ", id) dispatch(userAction.setUserId(id)) + dispatch(userAction.setEmail(data.user?.email ? data.user.email : null)) + dispatch(userAction.setUserName(data.user?.name ? data.user.name : null)) } if(status === 'unauthenticated'){ dispatch(userAction.setUserId(null)) diff --git a/apps/web/app/components/ui/app-sidebar.tsx b/apps/web/app/components/ui/app-sidebar.tsx new file mode 100644 index 0000000..8260a37 --- /dev/null +++ b/apps/web/app/components/ui/app-sidebar.tsx @@ -0,0 +1,216 @@ +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarTrigger, +} from '@workspace/ui/components/sidebar' + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@workspace/ui/components/collapsible' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { ChevronDown, ChevronUp, Key, LogOut, LucideLayoutDashboard, PlusCircle, User2, WorkflowIcon } from 'lucide-react' +import { useEffect, useState } from 'react' +import { createWorkflow, getAllCredentials, getAllWorkflows, getEmptyWorkflow } from '@/app/workflow/lib/config' +import { useAppDispatch, useAppSelector } from '@/app/hooks/redux' +import { userAction } from '@/store/slices/userSlice' +import { workflowActions } from '@/store/slices/workflowSlice' +import { toast } from 'sonner' +import { signOut } from 'next-auth/react' +import { useRouter } from 'next/navigation' + +export function AppSidebar() { + + const user = useAppSelector((s)=> s.user) + const flow = useAppSelector(s=>s.workflow) // workflow + const dispatch = useAppDispatch() + const router = useRouter() + const [selectedWorkflow, setSelectedWorkflow] = useState(flow.workflow_id) + const [workflow, setWorkflow] = useState>() + const [creds, setCreds] = useState>() + useEffect(()=>{ + async function getWorkflows(){ + const flows = await getAllWorkflows(); + if(flows) setWorkflow(flows) + } + + async function getCreds(){ + const credentials = await getAllCredentials(); + if(credentials) setCreds(credentials) + } + + if(!creds) getCreds() + if(!workflow) getWorkflows() + },[selectedWorkflow]) + + const workflowHandler = (wId: string)=>{ + dispatch(workflowActions.setWorkflowId(wId)) + } + const credHandler = (cId: string)=> { + // console.log(cId); + + } + const logout = async()=>{ + toast.info("Logging out...") + await signOut({redirect: false}) + dispatch(userAction.clearUser()) + dispatch(workflowActions.clearWorkflow()) + router.push('/login') + } + + const createNewWorkflow = async()=>{ + const workflow = await getEmptyWorkflow() + + if(workflow){ + const {id, isEmpty} = workflow + dispatch(workflowActions.setWorkflowId(id)) + dispatch(workflowActions.setWorkflowStatus(isEmpty)) + toast.info("You are in empty workflow") + } + else{ + const newWorkflow = await createWorkflow() + dispatch(workflowActions.clearWorkflow()) + setSelectedWorkflow(null) + dispatch(workflowActions.setWorkflowId(newWorkflow.id)) + dispatch(workflowActions.setWorkflowStatus(newWorkflow.isEmpty)) + toast.success("Workflow created") + } + } +// console.log(`workflow form ${workflow}`) + return ( + + + Logo + + + + + + + + + Create Workflow + + + + {/* WORKFLOWS LIST */} + + + + + + Workflows + + + + + + {workflow ? (workflow.length === 0 ? ( + + + Create Workflow + + + ) : (workflow.map((i:any) => ( + workflowHandler(i.id)} key={i.id}> + + {i.name} + + ) + ))) : (Loading... + ) + } + + + + + {/* CREDENTIALS LIST */} + + + + + + Credentials + + + + + + {creds ? (creds.length === 0 ? + ( + + No credentials avaliable + + + ) : creds.map((i:any) => ( + + + credential - {i.type} + + + ))) : ( + + + Loading... + + + ) + } + + + + + + + + + + + + + + +
+

{user.name}

+

+ {user.email} +

+
+ +
+
+ + + Dashboard + + + + Sign out + + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/app/workflow/lib/config.ts b/apps/web/app/workflow/lib/config.ts index c877bce..26ff178 100644 --- a/apps/web/app/workflow/lib/config.ts +++ b/apps/web/app/workflow/lib/config.ts @@ -1,6 +1,7 @@ import { BACKEND_URL } from "@repo/common/zod"; import { GoogleSheetsNodeExecutor } from "@repo/nodes"; import axios from "axios" +const date = new Date() export const getAvailableTriggers = async () => { try { const response = await axios.get( @@ -44,15 +45,42 @@ export const getCredentials = async(type: string)=>{ } } -const date = new Date() +export const getAllCredentials = async()=>{ + try{ + const res = await axios.get(`${BACKEND_URL}/user/getAllCreds`, { + withCredentials: true + }) + const data = res.data.data + console.log("credentails: ",data) + return data + } + catch(e){ + console.error("Error fetching credentials:", e); + throw e; + } +} + +export const getAllWorkflows = async()=>{ + try{ + const res = await axios.get(`${BACKEND_URL}/user/workflows`,{ + withCredentials: true + }) + const data = res.data.Data + console.log("workflows: ",data) + return data + }catch(e){ + console.error("Error fetching credentials:", e); + throw e; + } +} -export const createWorkflow = async(userId: string)=>{ +export const createWorkflow = async()=>{ try{ const response = await axios.post(`${BACKEND_URL}/user/create/workflow`, { Name:`workflow-${date.getTime()}`, - UserId: userId, + // UserId: userId, Config: {} },{ headers: { diff --git a/apps/web/app/workflow/page.tsx b/apps/web/app/workflow/page.tsx index 94c52f2..d5b4a5e 100644 --- a/apps/web/app/workflow/page.tsx +++ b/apps/web/app/workflow/page.tsx @@ -1,15 +1,27 @@ "use client"; +import { SidebarProvider, SidebarTrigger } from "@workspace/ui/components/sidebar"; import CreateWorkFlow from "../components/nodes/CreateWorkFlow"; +import { AppSidebar } from "../components/ui/app-sidebar"; // import WorkFlow from "../components/nodes/WorkFlow"; // import CreateWorkFlow from "../components/workflow"; export default function Workf() { return ( -
- {/* */} - + <> +
+
+ + + + {/* {children} */} +
+
+ +
+
+ ); } \ No newline at end of file diff --git a/apps/web/store/slices/userSlice.ts b/apps/web/store/slices/userSlice.ts index 186167f..9e5d51b 100644 --- a/apps/web/store/slices/userSlice.ts +++ b/apps/web/store/slices/userSlice.ts @@ -3,12 +3,16 @@ import {createSlice, PayloadAction } from '@reduxjs/toolkit' export type AuthStatus = 'Authenticated' | 'Unauthenticated' export interface UserSlice { userId: string | null; - status: AuthStatus + status: AuthStatus; + email: string | null; + name: string | null; } const initialState: UserSlice = { userId: null, - status: 'Unauthenticated' + status: 'Unauthenticated', + email: null, + name: null }; const userSlice = createSlice({ @@ -21,6 +25,12 @@ const userSlice = createSlice({ setUserStatus(state, action: PayloadAction){ state.status = action.payload; }, + setUserName(state, action: PayloadAction){ + state.name = action.payload + }, + setEmail(state, action: PayloadAction){ + state.email = action.payload + }, clearUser(){ return initialState; } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f749675..55f349c 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -36,7 +36,6 @@ export const NodeSchema = z.object({ export const WorkflowSchema = z.object({ Name: z.string(), - UserId: z.string(), Config: z.any(), }); diff --git a/packages/ui/package.json b/packages/ui/package.json index fd2861b..6ca5317 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,10 +7,14 @@ "lint": "eslint . --max-warnings 0" }, "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index c77fd72..6530d11 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -9,14 +9,13 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", @@ -26,6 +25,8 @@ const buttonVariants = cva( sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", }, }, defaultVariants: { @@ -37,8 +38,8 @@ const buttonVariants = cva( function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & @@ -50,6 +51,8 @@ function Button({ return ( diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 0000000..ae9fad0 --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 0000000..6c011e1 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@workspace/ui/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx new file mode 100644 index 0000000..4e9594c --- /dev/null +++ b/packages/ui/src/components/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@workspace/ui/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx new file mode 100644 index 0000000..b0bd8f7 --- /dev/null +++ b/packages/ui/src/components/sidebar.tsx @@ -0,0 +1,726 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +import { useIsMobile } from "@workspace/ui/hooks/use-mobile" +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/button" +import { Input } from "@workspace/ui/components/input" +import { Separator } from "@workspace/ui/components/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/sheet" +import { Skeleton } from "@workspace/ui/components/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +