From 0f2957da4a87462f5eba48ee1cfc14eff21eac8e Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 17 Nov 2024 22:38:57 -0500 Subject: [PATCH 1/2] init sidebar refactor --- .../src/app/[workspaceSlug]/AppSidebar.tsx | 134 +++ ...reateMenu.tsx => AppSidebarCreateMenu.tsx} | 47 +- ...spaceDropdown.tsx => AppSidebarHeader.tsx} | 50 +- .../app/[workspaceSlug]/AppSidebarPopover.tsx | 23 + .../AppSidebarPopoverContext.tsx | 85 ++ .../app/[workspaceSlug]/SettingsSidebar.tsx | 47 ++ .../app/[workspaceSlug]/SidebarContext.tsx | 15 - .../src/app/[workspaceSlug]/SidebarLink.tsx | 40 - .../src/app/[workspaceSlug]/SidebarMain.tsx | 67 -- .../app/[workspaceSlug]/SidebarNavMain.tsx | 88 ++ .../src/app/[workspaceSlug]/SidebarPanels.tsx | 89 -- .../[workspaceSlug]/SidebarPopoverSystem.tsx | 51 -- .../[workspaceSlug]/SidebarPopoverTargets.tsx | 125 --- .../app/[workspaceSlug]/SidebarSettings.tsx | 75 -- .../app/[workspaceSlug]/SidebarSystems.tsx | 108 --- .../app/[workspaceSlug]/SidebarWorkspace.tsx | 87 -- .../[workspaceSlug]/_SidebarPopoverSystem.tsx | 51 ++ .../app/[workspaceSlug]/_SidebarSystems.tsx | 108 +++ .../SearchDialog.tsx} | 0 .../src/app/[workspaceSlug]/layout.tsx | 55 +- apps/webservice/src/app/globals.css | 27 + apps/webservice/tailwind.config.ts | 12 + packages/ui/package.json | 7 +- packages/ui/src/hooks/use-mobile.tsx | 21 + packages/ui/src/sheet.tsx | 141 ++++ packages/ui/src/sidebar.tsx | 772 ++++++++++++++++++ packages/ui/tailwind.config.ts | 16 + packages/ui/unused.css | 31 + pnpm-lock.yaml | 24 +- 29 files changed, 1688 insertions(+), 708 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/AppSidebar.tsx rename apps/webservice/src/app/[workspaceSlug]/{SidebarCreateMenu.tsx => AppSidebarCreateMenu.tsx} (68%) rename apps/webservice/src/app/[workspaceSlug]/{SidebarWorkspaceDropdown.tsx => AppSidebarHeader.tsx} (65%) create mode 100644 apps/webservice/src/app/[workspaceSlug]/AppSidebarPopover.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/AppSidebarPopoverContext.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/SettingsSidebar.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarContext.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarLink.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarMain.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarNavMain.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarPanels.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarPopoverSystem.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarPopoverTargets.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarSettings.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarSystems.tsx delete mode 100644 apps/webservice/src/app/[workspaceSlug]/SidebarWorkspace.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_SidebarPopoverSystem.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_SidebarSystems.tsx rename apps/webservice/src/app/[workspaceSlug]/{Search.tsx => _components/SearchDialog.tsx} (100%) create mode 100644 packages/ui/src/hooks/use-mobile.tsx create mode 100644 packages/ui/src/sheet.tsx create mode 100644 packages/ui/src/sidebar.tsx create mode 100644 packages/ui/unused.css diff --git a/apps/webservice/src/app/[workspaceSlug]/AppSidebar.tsx b/apps/webservice/src/app/[workspaceSlug]/AppSidebar.tsx new file mode 100644 index 000000000..29601eea3 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/AppSidebar.tsx @@ -0,0 +1,134 @@ +import type { Workspace } from "@ctrlplane/db/schema"; +import React from "react"; +import { + IconCategory, + IconObjectScan, + IconPlant, + IconRocket, + IconRun, + IconShip, + IconVariable, +} from "@tabler/icons-react"; + +import { SidebarContent, SidebarHeader } from "@ctrlplane/ui/sidebar"; + +import { api } from "~/trpc/server"; +import { AppSidebarHeader } from "./AppSidebarHeader"; +import { AppSidebarPopover } from "./AppSidebarPopover"; +import { SidebarWithPopover } from "./AppSidebarPopoverContext"; +import { SidebarNavMain } from "./SidebarNavMain"; + +const navMain = (prefix: string) => [ + { + title: "Systems", + url: `${prefix}/systems`, + icon: IconCategory, + isActive: true, + items: [ + { + title: "Dependencies", + url: `${prefix}/dependencies`, + isActive: true, + }, + ], + }, + { + title: "Resources", + url: `${prefix}/resources`, + popoverId: "resources", + icon: IconObjectScan, + isActive: true, + items: [ + { + title: "List", + url: `${prefix}/targets`, + }, + { + title: "Providers", + url: `${prefix}/target-providers`, + }, + { + title: "Groups", + url: `${prefix}/target-metadata-groups`, + }, + { + title: "Views", + url: `${prefix}/target-views`, + }, + ], + }, + { + title: "Jobs", + url: "#", + icon: IconRocket, + items: [ + { + title: "Agents", + url: "#", + }, + { + title: "Runs", + url: "#", + }, + ], + }, +]; + +export const AppSidebar: React.FC<{ workspace: Workspace }> = async ({ + workspace, +}) => { + const [workspaces, viewer, systems] = await Promise.all([ + api.workspace.list(), + api.user.viewer(), + api.system.list({ workspaceId: workspace.id }), + ]); + + const navSystem = systems.items.map((s) => ({ + title: s.name, + url: "#", + isActive: true, + items: [ + { + title: "Environments", + icon: IconPlant, + url: "#", + }, + { + title: "Deployements", + icon: IconShip, + url: "#", + }, + { + title: "Runbooks", + icon: IconRun, + url: "#", + }, + { + title: "Variable Sets", + icon: IconVariable, + url: "#", + }, + ], + })); + + return ( + + + + + + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx b/apps/webservice/src/app/[workspaceSlug]/AppSidebarCreateMenu.tsx similarity index 68% rename from apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx rename to apps/webservice/src/app/[workspaceSlug]/AppSidebarCreateMenu.tsx index 121f5683e..f683fb658 100644 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/AppSidebarCreateMenu.tsx @@ -1,5 +1,8 @@ +"use client"; + import type { Workspace } from "@ctrlplane/db/schema"; import { useState } from "react"; +import { useParams } from "next/navigation"; import { IconPlus } from "@tabler/icons-react"; import { Button } from "@ctrlplane/ui/button"; @@ -12,17 +15,35 @@ import { DropdownMenuTrigger, } from "@ctrlplane/ui/dropdown-menu"; +import { api } from "~/trpc/react"; import { CreateDeploymentDialog } from "./_components/CreateDeployment"; import { CreateReleaseDialog } from "./_components/CreateRelease"; import { CreateSystemDialog } from "./_components/CreateSystem"; import { CreateTargetDialog } from "./_components/CreateTarget"; import { CreateSessionDialog } from "./_components/terminal/CreateDialogSession"; -export const SidebarCreateMenu: React.FC<{ +export const AppSidebarCreateMenu: React.FC<{ workspace: Workspace; - deploymentId?: string; - systemId?: string; -}> = (props) => { +}> = ({ workspace }) => { + const { deploymentSlug, workspaceSlug, systemSlug } = useParams<{ + workspaceSlug: string; + systemSlug?: string; + deploymentSlug?: string; + }>(); + + const system = api.system.bySlug.useQuery( + { workspaceSlug, systemSlug: systemSlug ?? "" }, + { enabled: systemSlug != null }, + ); + const deployment = api.deployment.bySlug.useQuery( + { + workspaceSlug, + systemSlug: systemSlug ?? "", + deploymentSlug: deploymentSlug ?? "", + }, + { enabled: deploymentSlug != null && systemSlug != null }, + ); + const [open, setOpen] = useState(false); return ( @@ -42,19 +63,26 @@ export const SidebarCreateMenu: React.FC<{ > setOpen(false)} > e.preventDefault()}> New System - setOpen(false)}> + setOpen(false)} + > e.preventDefault()}> New Deployment - setOpen(false)}> + setOpen(false)} + > e.preventDefault()}> New Release @@ -75,7 +103,10 @@ export const SidebarCreateMenu: React.FC<{ - setOpen(false)}> + setOpen(false)} + > e.preventDefault()}> Bootstrap Target diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarWorkspaceDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/AppSidebarHeader.tsx similarity index 65% rename from apps/webservice/src/app/[workspaceSlug]/SidebarWorkspaceDropdown.tsx rename to apps/webservice/src/app/[workspaceSlug]/AppSidebarHeader.tsx index 600f09664..7860aedb4 100644 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarWorkspaceDropdown.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/AppSidebarHeader.tsx @@ -1,9 +1,9 @@ "use client"; -import type { Workspace } from "@ctrlplane/db/schema"; +import type { System, Workspace } from "@ctrlplane/db/schema"; import Link from "next/link"; -import { IconCheck, IconChevronDown } from "@tabler/icons-react"; -import { signOut, useSession } from "next-auth/react"; +import { IconCheck, IconChevronDown, IconSearch } from "@tabler/icons-react"; +import { signOut } from "next-auth/react"; import { Button } from "@ctrlplane/ui/button"; import { @@ -19,14 +19,17 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@ctrlplane/ui/dropdown-menu"; +import { SidebarMenu, SidebarMenuItem } from "@ctrlplane/ui/sidebar"; import { api } from "~/trpc/react"; +import { SearchDialog } from "./_components/SearchDialog"; +import { AppSidebarCreateMenu } from "./AppSidebarCreateMenu"; -export const SidebarWorkspaceDropdown: React.FC<{ workspace: Workspace }> = ({ - workspace, -}) => { - const { data } = useSession(); - const workspaces = api.workspace.list.useQuery(); +const WorkspaceDropdown: React.FC<{ + workspace: Workspace; + workspaces: Workspace[]; + viewer: { email: string }; +}> = ({ workspace, workspaces, viewer }) => { const update = api.profile.update.useMutation(); return ( @@ -53,9 +56,9 @@ export const SidebarWorkspaceDropdown: React.FC<{ workspace: Workspace }> = ({ - {data?.user.email} + {viewer.email} - {workspaces.data?.map((ws) => ( + {workspaces.map((ws) => ( = ({ ); }; + +export const AppSidebarHeader: React.FC<{ + systems: System[]; + workspace: Workspace; + workspaces: Workspace[]; + viewer: { email: string }; +}> = ({ workspace, workspaces, viewer }) => { + return ( + + +
+ +
+ + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopover.tsx b/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopover.tsx new file mode 100644 index 000000000..81d0e4fef --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopover.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; + +import { Popover, PopoverAnchor, PopoverContent } from "@ctrlplane/ui/popover"; + +import { useSidebarPopover } from "./AppSidebarPopoverContext"; + +export const AppSidebarPopover: React.FC = () => { + const { activeSidebarItem } = useSidebarPopover(); + return ( + + + + {activeSidebarItem} + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopoverContext.tsx b/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopoverContext.tsx new file mode 100644 index 000000000..e7367ff99 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/AppSidebarPopoverContext.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; + +import { + Sidebar, + SidebarMenuItem, + SidebarMenuSubItem, +} from "@ctrlplane/ui/sidebar"; + +type AppSidebarPopoverContextType = { + activeSidebarItem: string | null; + setActiveSidebarItem: (item: string | null) => void; +}; + +const AppSidebarPopoverContext = createContext({ + activeSidebarItem: null, + setActiveSidebarItem: () => {}, +}); + +export const useSidebarPopover = () => useContext(AppSidebarPopoverContext); + +export const AppSidebarPopoverProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [activeSidebarItem, setActiveSidebarItem] = useState( + null, + ); + return ( + + {children} + + ); +}; + +export const SidebarWithPopover: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const { setActiveSidebarItem } = useSidebarPopover(); + return ( + setActiveSidebarItem(null)}> + {children} + + ); +}; + +export const SidebarMenuItemWithPopover: React.FC<{ + children: React.ReactNode; + popoverId?: string; +}> = ({ children, popoverId }) => { + const { setActiveSidebarItem } = useSidebarPopover(); + return ( + { + if (popoverId != null) { + setActiveSidebarItem(popoverId); + e.stopPropagation(); + } + }} + > + {children} + + ); +}; + +export const SidebarMenuSubItemWithPopover: React.FC<{ + children: React.ReactNode; + popoverId?: string; +}> = ({ children, popoverId }) => { + const { setActiveSidebarItem } = useSidebarPopover(); + return ( + { + if (popoverId != null) { + setActiveSidebarItem(popoverId); + e.stopPropagation(); + } + }} + > + {children} + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SettingsSidebar.tsx b/apps/webservice/src/app/[workspaceSlug]/SettingsSidebar.tsx new file mode 100644 index 000000000..bf53f7c6c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/SettingsSidebar.tsx @@ -0,0 +1,47 @@ +import type { Workspace } from "@ctrlplane/db/schema"; +import Link from "next/link"; +import { IconChevronLeft } from "@tabler/icons-react"; + +import { Sidebar, SidebarContent, SidebarHeader } from "@ctrlplane/ui/sidebar"; + +import { SidebarNavMain } from "./SidebarNavMain"; + +export const SettingsSidebar: React.FC<{ workspace: Workspace }> = ({ + workspace, +}) => { + return ( + + +
+ +
+ +
+
Settings
+ +
+
+ + + + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarContext.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarContext.tsx deleted file mode 100644 index 24e43b52f..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarContext.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { createContext, useContext } from "react"; - -type SidebarContextType = { - activeSidebarItem: string | null; - setActiveSidebarItem: (item: string | null) => void; -}; - -export const SidebarContext = createContext({ - activeSidebarItem: null, - setActiveSidebarItem: () => {}, -}); - -export const useSidebar = () => useContext(SidebarContext); diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarLink.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarLink.tsx deleted file mode 100644 index c3db604cf..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarLink.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -import { cn } from "@ctrlplane/ui"; - -import { useSidebar } from "./SidebarContext"; - -export const SidebarLink: React.FC<{ - href: string; - children: React.ReactNode; - exact?: boolean; - className?: string; - hideActiveEffect?: boolean; -}> = ({ href, exact, children, className, hideActiveEffect }) => { - const { setActiveSidebarItem } = useSidebar(); - const pathname = usePathname(); - const active = hideActiveEffect - ? false - : exact - ? pathname === href - : pathname.startsWith(href); - return ( - { - console.log("setting null"); - setActiveSidebarItem(null); - }} - className={cn( - className, - active ? "bg-neutral-800/70" : "hover:bg-neutral-800/50", - "flex items-center gap-2 rounded-md px-2 py-1", - )} - > - {children} - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarMain.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarMain.tsx deleted file mode 100644 index d7ecbb2a1..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarMain.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import type { System, Workspace } from "@ctrlplane/db/schema"; -import { useParams } from "next/navigation"; -import { IconSearch } from "@tabler/icons-react"; - -import { Button } from "@ctrlplane/ui/button"; - -import { api } from "~/trpc/react"; -import { SearchDialog } from "./Search"; -import { SidebarCreateMenu } from "./SidebarCreateMenu"; -import { SidebarSystems } from "./SidebarSystems"; -import { SidebarWorkspace } from "./SidebarWorkspace"; -import { SidebarWorkspaceDropdown } from "./SidebarWorkspaceDropdown"; - -export const SidebarMain: React.FC<{ - workspace: Workspace; - systems: System[]; -}> = ({ workspace, systems }) => { - const { deploymentSlug, workspaceSlug, systemSlug } = useParams<{ - workspaceSlug: string; - systemSlug?: string; - deploymentSlug?: string; - }>(); - - const system = systems.find((s) => s.slug === systemSlug); - const deployment = api.deployment.bySlug.useQuery( - { - workspaceSlug, - systemSlug: systemSlug ?? "", - deploymentSlug: deploymentSlug ?? "", - }, - { enabled: deploymentSlug != null && systemSlug != null }, - ); - - return ( -
-
-
-
- -
- - - - - - -
-
- - - - -
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarNavMain.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarNavMain.tsx new file mode 100644 index 000000000..465bbfa3c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/SidebarNavMain.tsx @@ -0,0 +1,88 @@ +import { IconChevronRight } from "@tabler/icons-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@ctrlplane/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuSub, + SidebarMenuSubButton, +} from "@ctrlplane/ui/sidebar"; + +import { + SidebarMenuItemWithPopover, + SidebarMenuSubItemWithPopover, +} from "./AppSidebarPopoverContext"; + +export const SidebarNavMain: React.FC<{ + title: string; + items: { + popoverId?: string; + title: string; + url: string; + icon?: any; + isOpen?: boolean; + items?: { + popoverId?: string; + icon?: any; + title: string; + url: string; + exact?: boolean; + }[]; + }[]; +}> = ({ title, items }) => { + return ( + + {title} + + {items.map((item) => ( + + + + + {item.icon && } + {item.title} + + + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items.map((subItem) => ( + + + + {subItem.icon && ( + + )} + {subItem.title} + + + + ))} + + + + ) : null} + + + ))} + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarPanels.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarPanels.tsx deleted file mode 100644 index 21c4ed41d..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarPanels.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import type { System, Workspace } from "@ctrlplane/db/schema"; -import React, { useState } from "react"; -import { usePathname } from "next/navigation"; - -import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@ctrlplane/ui/resizable"; - -import { SidebarContext } from "./SidebarContext"; -import { SidebarMain } from "./SidebarMain"; -import { SidebarPopoverSystem } from "./SidebarPopoverSystem"; -import { SidebarPopoverTargets } from "./SidebarPopoverTargets"; -import { SidebarSettings } from "./SidebarSettings"; - -export const SidebarPanels: React.FC<{ - children: React.ReactNode; - workspace: Workspace; - systems: System[]; -}> = ({ children, systems, workspace }) => { - const pathname = usePathname(); - const isSettingsPage = pathname.includes("/settings"); - const [open, setOpen] = useState(false); - const [activeSidebarItem, setActiveSidebarItem] = useState( - null, - ); - - return ( - - - - e.preventDefault()} - className="focus-visible:outline-none" - > - setOpen(true)} - > -
- {isSettingsPage ? ( - - ) : ( - - )} -
-
-
- - {activeSidebarItem === "targets" && ( - - )} - {activeSidebarItem?.startsWith("systems:") && ( - - )} - -
- - - { - setOpen(false); - setActiveSidebarItem(null); - }} - > - {children} - -
{" "} -
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverSystem.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverSystem.tsx deleted file mode 100644 index 804c0aad7..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverSystem.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import type { Workspace } from "@ctrlplane/db/schema"; - -import { api } from "~/trpc/react"; -import { SidebarLink } from "./SidebarLink"; - -export const SidebarPopoverSystem: React.FC<{ - systemId: string; - workspace: Workspace; -}> = ({ workspace, systemId }) => { - const system = api.system.byId.useQuery(systemId); - const environments = api.environment.bySystemId.useQuery(systemId); - const deployments = api.deployment.bySystemId.useQuery(systemId); - return ( -
-
{system.data?.name}
- -
-
- Environments -
-
- {environments.data?.map(({ name }) => ( - - {name} - - ))} -
-
- -
-
- Deployments -
-
- {deployments.data?.map(({ id, name, slug }) => ( - - {name} - - ))} -
-
-
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverTargets.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverTargets.tsx deleted file mode 100644 index 119631da4..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarPopoverTargets.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import type { Workspace } from "@ctrlplane/db/schema"; -import { usePathname } from "next/navigation"; -import { IconBookmark } from "@tabler/icons-react"; -import LZString from "lz-string"; - -import { Badge } from "@ctrlplane/ui/badge"; -import { - ComparisonOperator, - FilterType, -} from "@ctrlplane/validators/conditions"; -import { ResourceFilterType } from "@ctrlplane/validators/resources"; - -import { api } from "~/trpc/react"; -import { TargetIcon } from "./_components/TargetIcon"; -import { SidebarLink } from "./SidebarLink"; - -export const SidebarPopoverTargets: React.FC<{ workspace: Workspace }> = ({ - workspace, -}) => { - const pathname = usePathname(); - const kinds = api.workspace.resourceKinds.useQuery(workspace.id); - - const views = api.resource.view.list.useQuery(workspace.id); - const viewsWithHash = views.data?.map((view) => ({ - ...view, - hash: LZString.compressToEncodedURIComponent(JSON.stringify(view.filter)), - })); - - const recentlyAdded = api.resource.byWorkspaceId.list.useQuery({ - workspaceId: workspace.id, - orderBy: [{ property: "createdAt", direction: "desc" }], - limit: 5, - }); - - const totalTargets = - (recentlyAdded.data?.total ?? 0) - (recentlyAdded.data?.items.length ?? 0); - - return ( -
-
Targets
- -
-
- Saved Views -
-
-
- No saved filters found. -
- {viewsWithHash != null && viewsWithHash.length > 0 && ( - <> - {viewsWithHash.map(({ id, name, hash }) => ( - - - {name} - - ))} - - )} -
-
- -
-
- Kinds -
-
- {kinds.data?.map(({ version, kind, count }) => ( - - - {kind} - - {count} - - - ))} -
-
- -
-
- Recently Added Targets -
-
- {recentlyAdded.data?.items.map((resource) => ( - - {resource.name} - - ))} - {totalTargets > 0 && ( -
- +{totalTargets} other targets -
- )} -
-
-
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarSettings.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarSettings.tsx deleted file mode 100644 index 14fa4b3c8..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarSettings.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import Link from "next/link"; -import { IconBuilding, IconChevronLeft, IconUser } from "@tabler/icons-react"; - -import { SidebarLink } from "./SidebarLink"; - -const WorkspaceSettings: React.FC<{ workspaceSlug: string }> = ({ - workspaceSlug, -}) => { - return ( -
-
- Workspace -
- -
- - Overview - - - General - - - Members - - - Integrations - -
-
- ); -}; - -const AccountSettings: React.FC<{ workspaceSlug: string }> = ({ - workspaceSlug, -}) => { - return ( -
-
- My account -
- -
- - Profile - - - API - -
-
- ); -}; - -export const SidebarSettings: React.FC<{ workspaceSlug: string }> = ({ - workspaceSlug, -}) => { - return ( -
-
- -
- -
-
Settings
- -
- - - -
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarSystems.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarSystems.tsx deleted file mode 100644 index 2e26ef95a..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarSystems.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import type { System, Workspace } from "@ctrlplane/db/schema"; -import { useState } from "react"; -import { useParams } from "next/navigation"; -import { - IconChevronRight, - IconPlant, - IconPlus, - IconRun, - IconShip, - IconVariable, -} from "@tabler/icons-react"; -import _ from "lodash"; -import { useLocalStorage } from "react-use"; - -import { cn } from "@ctrlplane/ui"; -import { Button } from "@ctrlplane/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@ctrlplane/ui/collapsible"; - -import { CreateSystemDialog } from "./_components/CreateSystem"; -import { useSidebar } from "./SidebarContext"; -import { SidebarLink } from "./SidebarLink"; - -const SystemCollapsible: React.FC<{ system: System }> = ({ system }) => { - const { setActiveSidebarItem } = useSidebar(); - const [open, setOpen] = useLocalStorage( - `sidebar-systems-${system.id}`, - "false", - ); - const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); - return ( - setOpen(open === "true" ? "false" : "true")} - className="space-y-1 text-sm" - onMouseEnter={() => setActiveSidebarItem(`systems:${system.id}`)} - > - - {system.name} - - - - - Deployments - - - Environments - - - Runbooks - - - Variable - Sets - - - - ); -}; - -export const SidebarSystems: React.FC<{ - workspace: Workspace; - systems: System[]; -}> = ({ workspace, systems }) => { - const [open, setOpen] = useState(true); - return ( - - - Your systems - - - - {systems.length === 0 && ( - - - - )} - {systems.map((system) => ( - - ))} - - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarWorkspace.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarWorkspace.tsx deleted file mode 100644 index a27c148f3..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarWorkspace.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useParams } from "next/navigation"; -import { - IconCategory, - IconChevronRight, - IconRocket, - IconTarget, -} from "@tabler/icons-react"; - -import { cn } from "@ctrlplane/ui"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@ctrlplane/ui/collapsible"; - -import { useSidebar } from "./SidebarContext"; -import { SidebarLink } from "./SidebarLink"; - -export const SidebarWorkspace: React.FC = () => { - const [open, setOpen] = useState(true); - const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); - const { setActiveSidebarItem } = useSidebar(); - return ( - - - Workspace - - - - {/* - Dashboard - */} - - Systems - -
-
- - Dependencies - -
-
- -
setActiveSidebarItem("targets")}> - - Targets - -
-
- List - - Providers - - - Groups - - - Views - -
-
-
- -
- - Jobs - -
-
- - Agents - - - Triggered - -
-
-
-
-
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_SidebarPopoverSystem.tsx b/apps/webservice/src/app/[workspaceSlug]/_SidebarPopoverSystem.tsx new file mode 100644 index 000000000..5416b74e2 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_SidebarPopoverSystem.tsx @@ -0,0 +1,51 @@ +// "use client"; + +// import type { Workspace } from "@ctrlplane/db/schema"; + +// import { api } from "~/trpc/react"; +// import { SidebarLink } from "./SidebarLink"; + +// export const SidebarPopoverSystem: React.FC<{ +// systemId: string; +// workspace: Workspace; +// }> = ({ workspace, systemId }) => { +// const system = api.system.byId.useQuery(systemId); +// const environments = api.environment.bySystemId.useQuery(systemId); +// const deployments = api.deployment.bySystemId.useQuery(systemId); +// return ( +//
+//
{system.data?.name}
+ +//
+//
+// Environments +//
+//
+// {environments.data?.map(({ name }) => ( +// +// {name} +// +// ))} +//
+//
+ +//
+//
+// Deployments +//
+//
+// {deployments.data?.map(({ id, name, slug }) => ( +// +// {name} +// +// ))} +//
+//
+//
+// ); +// }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_SidebarSystems.tsx b/apps/webservice/src/app/[workspaceSlug]/_SidebarSystems.tsx new file mode 100644 index 000000000..ef7a0e142 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_SidebarSystems.tsx @@ -0,0 +1,108 @@ +// "use client"; + +// import type { System, Workspace } from "@ctrlplane/db/schema"; +// import { useState } from "react"; +// import { useParams } from "next/navigation"; +// import { +// IconChevronRight, +// IconPlant, +// IconPlus, +// IconRun, +// IconShip, +// IconVariable, +// } from "@tabler/icons-react"; +// import _ from "lodash"; +// import { useLocalStorage } from "react-use"; + +// import { cn } from "@ctrlplane/ui"; +// import { Button } from "@ctrlplane/ui/button"; +// import { +// Collapsible, +// CollapsibleContent, +// CollapsibleTrigger, +// } from "@ctrlplane/ui/collapsible"; + +// import { CreateSystemDialog } from "./_components/CreateSystem"; +// import { useSidebar } from "./SidebarContext"; +// import { SidebarLink } from "./SidebarLink"; + +// const SystemCollapsible: React.FC<{ system: System }> = ({ system }) => { +// const { setActiveSidebarItem } = useSidebar(); +// const [open, setOpen] = useLocalStorage( +// `sidebar-systems-${system.id}`, +// "false", +// ); +// const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); +// return ( +// setOpen(open === "true" ? "false" : "true")} +// className="space-y-1 text-sm" +// onMouseEnter={() => setActiveSidebarItem(`systems:${system.id}`)} +// > +// +// {system.name} +// +// +// +// +// Deployments +// +// +// Environments +// +// +// Runbooks +// +// +// Variable +// Sets +// +// +// +// ); +// }; + +// export const SidebarSystems: React.FC<{ +// workspace: Workspace; +// systems: System[]; +// }> = ({ workspace, systems }) => { +// const [open, setOpen] = useState(true); +// return ( +// +// +// Your systems +// +// +// +// {systems.length === 0 && ( +// +// +// +// )} +// {systems.map((system) => ( +// +// ))} +// +// +// ); +// }; diff --git a/apps/webservice/src/app/[workspaceSlug]/Search.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/SearchDialog.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/Search.tsx rename to apps/webservice/src/app/[workspaceSlug]/_components/SearchDialog.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/layout.tsx index bd579af9f..2010fc986 100644 --- a/apps/webservice/src/app/[workspaceSlug]/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/layout.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import { notFound, redirect } from "next/navigation"; import { auth } from "@ctrlplane/auth"; +import { SidebarInset, SidebarProvider } from "@ctrlplane/ui/sidebar"; import { api } from "~/trpc/server"; import { EnvironmentDrawer } from "./_components/environment-drawer/EnvironmentDrawer"; @@ -12,45 +13,47 @@ import { ReleaseDrawer } from "./_components/release-drawer/ReleaseDrawer"; import { TargetDrawer } from "./_components/target-drawer/TargetDrawer"; import { TerminalSessionsProvider } from "./_components/terminal/TerminalSessionsProvider"; import { VariableSetDrawer } from "./_components/variable-set-drawer/VariableSetDrawer"; -import { SidebarPanels } from "./SidebarPanels"; +import { AppSidebar } from "./AppSidebar"; +import { AppSidebarPopoverProvider } from "./AppSidebarPopoverContext"; const TerminalDrawer = dynamic( () => import("./_components/terminal/TerminalSessionsDrawer"), { ssr: false }, ); -export default async function WorkspaceLayout({ - children, - params, -}: { +type Props = { children: React.ReactNode; params: { workspaceSlug: string }; -}) { +}; + +export default async function WorkspaceLayout({ + children, + params: { workspaceSlug }, +}: Props) { const session = await auth(); if (session == null) redirect("/login"); - const workspace = await api.workspace - .bySlug(params.workspaceSlug) - .catch(() => null); - + const workspace = await api.workspace.bySlug(workspaceSlug).catch(() => null); if (workspace == null) notFound(); - const systems = await api.system.list({ workspaceId: workspace.id }); return ( - -
- - {children} - -
- - - - - - - - -
+ + + + + {children} + + + + + + + + + + + + + ); } diff --git a/apps/webservice/src/app/globals.css b/apps/webservice/src/app/globals.css index 068a498ef..84fce9a92 100644 --- a/apps/webservice/src/app/globals.css +++ b/apps/webservice/src/app/globals.css @@ -30,6 +30,15 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { @@ -58,6 +67,15 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + + --sidebar-background: 0 0% 0%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } @@ -72,3 +90,12 @@ scrollbar-width: none; /* Firefox */ } } + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/webservice/tailwind.config.ts b/apps/webservice/tailwind.config.ts index 68bd41636..0a2ed5a00 100644 --- a/apps/webservice/tailwind.config.ts +++ b/apps/webservice/tailwind.config.ts @@ -14,6 +14,18 @@ export default { sans: ["var(--font-geist-sans)", ...fontFamily.sans], mono: ["var(--font-geist-mono)", ...fontFamily.mono], }, + colors: { + sidebar: { + DEFAULT: "hsl(var(--sidebar-background))", + foreground: "hsl(var(--sidebar-foreground))", + primary: "hsl(var(--sidebar-primary))", + "primary-foreground": "hsl(var(--sidebar-primary-foreground))", + accent: "hsl(var(--sidebar-accent))", + "accent-foreground": "hsl(var(--sidebar-accent-foreground))", + border: "hsl(var(--sidebar-border))", + ring: "hsl(var(--sidebar-ring))", + }, + }, }, }, plugins: [require("@tailwindcss/typography"), require("tailwind-scrollbar")], diff --git a/packages/ui/package.json b/packages/ui/package.json index 580a90344..80c9dbebf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,7 +27,7 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.1.0", - "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-icons": "^1.3.0", @@ -38,11 +38,11 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", "@tabler/icons-react": "^3.17.0", "class-variance-authority": "^0.7.0", "cmdk": "^1.0.0", @@ -55,6 +55,7 @@ "react-resizable-panels": "^2.0.20", "react-stately": "^3.31.1", "recharts": "^2.1.12", + "sidebar": "^1.0.2", "sonner": "^1.4.41", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", diff --git a/packages/ui/src/hooks/use-mobile.tsx b/packages/ui/src/hooks/use-mobile.tsx new file mode 100644 index 000000000..a93d58393 --- /dev/null +++ b/packages/ui/src/hooks/use-mobile.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined, + ); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/packages/ui/src/sheet.tsx b/packages/ui/src/sheet.tsx new file mode 100644 index 000000000..dcac30a67 --- /dev/null +++ b/packages/ui/src/sheet.tsx @@ -0,0 +1,141 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { cva } from "class-variance-authority"; + +import { cn } from "./index"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/packages/ui/src/sidebar.tsx b/packages/ui/src/sidebar.tsx new file mode 100644 index 000000000..b9b7c0de3 --- /dev/null +++ b/packages/ui/src/sidebar.tsx @@ -0,0 +1,772 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { ViewVerticalIcon } from "@radix-ui/react-icons"; +import { Slot } from "@radix-ui/react-slot"; +import { cva } from "class-variance-authority"; + +import { Button } from "./button"; +import { useIsMobile } from "./hooks/use-mobile"; +import { cn } from "./index"; +import { Input } from "./input"; +import { Separator } from "./separator"; +import { Sheet, SheetContent } from "./sheet"; +import { Skeleton } from "./skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./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 SidebarContext = { + 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; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref, + ) => { + 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} +
+
+
+ ); + }, +); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref, + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); + }, +); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +