diff --git a/apps/app/src/actions/sidebar.ts b/apps/app/src/actions/sidebar.ts deleted file mode 100644 index 16d12e8e8..000000000 --- a/apps/app/src/actions/sidebar.ts +++ /dev/null @@ -1,24 +0,0 @@ -'use server'; - -import { addYears } from 'date-fns'; -import { createSafeActionClient } from 'next-safe-action'; -import { cookies } from 'next/headers'; -import { z } from 'zod'; - -const schema = z.object({ - isCollapsed: z.boolean(), -}); - -export const updateSidebarState = createSafeActionClient() - .inputSchema(schema) - .action(async ({ parsedInput }) => { - const cookieStore = await cookies(); - - cookieStore.set({ - name: 'sidebar-collapsed', - value: JSON.stringify(parsedInput.isCollapsed), - expires: addYears(new Date(), 1), - }); - - return { success: true }; - }); diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index c54c4ac87..9ee8bdaf2 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,10 +1,9 @@ -import { AnimatedLayout } from '@/components/animated-layout'; +import { AppSidebar } from '@/components/app-sidebar'; import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; import { Header } from '@/components/header'; import { AssistantSheet } from '@/components/sheets/assistant-sheet'; -import { Sidebar } from '@/components/sidebar'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; -import { SidebarProvider } from '@/context/sidebar-context'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { auth } from '@/utils/auth'; import { db } from '@db'; import dynamic from 'next/dynamic'; @@ -29,7 +28,7 @@ export default async function Layout({ const { orgId: requestedOrgId } = await params; const cookieStore = await cookies(); - const isCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true'; + const defaultOpen = cookieStore.get('sidebar_state')?.value !== 'false'; let publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; // Check if user has access to this organization @@ -89,8 +88,9 @@ export default async function Layout({ triggerJobId={onboarding?.triggerJobId || undefined} initialToken={publicAccessToken || undefined} > - - } isCollapsed={isCollapsed}> + + + {onboarding?.triggerJobId && }
@@ -100,7 +100,7 @@ export default async function Layout({ - + diff --git a/apps/app/src/components/animated-layout.tsx b/apps/app/src/components/animated-layout.tsx deleted file mode 100644 index a41fc493d..000000000 --- a/apps/app/src/components/animated-layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { cn } from '@comp/ui/cn'; - -interface AnimatedLayoutProps { - children: React.ReactNode; - sidebar: React.ReactNode; - isCollapsed: boolean; - blurred?: boolean; -} - -export function AnimatedLayout({ children, sidebar, isCollapsed, blurred }: AnimatedLayoutProps) { - return ( -
- -
{children}
-
- ); -} diff --git a/apps/app/src/components/app-sidebar.tsx b/apps/app/src/components/app-sidebar.tsx new file mode 100644 index 000000000..18137e100 --- /dev/null +++ b/apps/app/src/components/app-sidebar.tsx @@ -0,0 +1,33 @@ +import { getOrganizations } from '@/data/getOrganizations'; +import type { Organization } from '@db'; +import { MainMenu } from './main-menu'; +import { OrganizationSwitcher } from './organization-switcher'; +import { SidebarCollapseButton } from './sidebar-collapse-button'; +import { SidebarLogo } from './sidebar-logo'; +import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from './ui/sidebar'; + +export async function AppSidebar({ organization }: { organization: Organization | null }) { + const { organizations } = await getOrganizations(); + + return ( + + +
+ +
+
+ + +
+
+ + + + + + + + +
+ ); +} diff --git a/apps/app/src/components/layout/app-shell.tsx b/apps/app/src/components/layout/app-shell.tsx new file mode 100644 index 000000000..68a209489 --- /dev/null +++ b/apps/app/src/components/layout/app-shell.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { cn } from '@comp/ui/cn'; +import { useEffect, useState } from 'react'; + +const SIDEBAR_COOKIE_NAME = 'sidebar-collapsed'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +interface AppShellProps { + children: React.ReactNode; + sidebar: React.ReactNode; + defaultCollapsed?: boolean; +} + +export function AppShell({ children, sidebar, defaultCollapsed = false }: AppShellProps) { + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + const savedState = localStorage.getItem(SIDEBAR_COOKIE_NAME); + if (savedState !== null) { + setIsCollapsed(savedState === 'true'); + } + }, []); + + const toggleSidebar = () => { + const newState = !isCollapsed; + setIsCollapsed(newState); + localStorage.setItem(SIDEBAR_COOKIE_NAME, String(newState)); + document.cookie = `${SIDEBAR_COOKIE_NAME}=${newState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }; + + return ( +
+ {/* Sidebar Container - Fixed width rail on desktop */} + + + {/* Main Content */} +
{children}
+
+ ); +} diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx index a165522dd..c4eb437e4 100644 --- a/apps/app/src/components/main-menu.tsx +++ b/apps/app/src/components/main-menu.tsx @@ -1,10 +1,13 @@ 'use client'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; -import { cn } from '@comp/ui/cn'; import { Icons } from '@comp/ui/icons'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; import { FlaskConical, Gauge, @@ -39,17 +42,11 @@ interface ItemProps { item: MenuItem; isActive: boolean; disabled: boolean; - isCollapsed?: boolean; onItemClick?: () => void; itemRef: (el: HTMLDivElement | null) => void; } -export function MainMenu({ - organizationId, - organization, - isCollapsed = false, - onItemClick, -}: Props) { +export function MainMenu({ organizationId, organization, onItemClick }: Props) { const pathname = usePathname(); const [activeStyle, setActiveStyle] = useState({ top: '0px', height: '0px' }); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -203,120 +200,77 @@ export function MainMenu({ }, [activeIndex]); return ( - + + {visibleItems.map((item, index) => { + const isActive = isPathActive(item.path); + return ( + { + itemRefs.current[index] = el; + }} + /> + ); + })} + + ); } -const Item = ({ - organizationId, - item, - isActive, - disabled, - isCollapsed = false, - onItemClick, - itemRef, -}: ItemProps) => { +const Item = ({ organizationId, item, isActive, disabled, onItemClick, itemRef }: ItemProps) => { const Icon = item.icon; const linkDisabled = disabled || item.disabled; const itemPath = item.path.replace(':organizationId', organizationId ?? ''); + const { isMobile, setOpen } = useSidebar(); + + const handleClick = () => { + if (isMobile) { + setOpen(false); + } + onItemClick?.(); + }; if (linkDisabled) { return ( -
- - - - - - {isCollapsed && Coming Soon} - - -
+ + + + Coming Soon + + ); } return ( -
- - - - - - {isCollapsed && ( - -
- {item.name} - {item.badge && ( - - {item.badge.text} - - )} -
-
+ + + + + {item.name} + {item.badge && ( + + {item.badge.text} + )} -
-
-
+ + + ); }; type Props = { organizationId?: string; organization?: { advancedModeEnabled?: boolean } | null; - isCollapsed?: boolean; onItemClick?: () => void; }; diff --git a/apps/app/src/components/mobile-menu.tsx b/apps/app/src/components/mobile-menu.tsx index 2eeeef227..3d911d5c6 100644 --- a/apps/app/src/components/mobile-menu.tsx +++ b/apps/app/src/components/mobile-menu.tsx @@ -10,7 +10,6 @@ import { OrganizationSwitcher } from './organization-switcher'; interface MobileMenuProps { organizations: Organization[]; - isCollapsed?: boolean; organizationId?: string; } @@ -40,11 +39,7 @@ export function MobileMenu({ organizationId, organizations }: MobileMenuProps) {
- + (null); @@ -174,8 +175,7 @@ export function OrganizationSwitcher({ variant="ghost" size={isCollapsed ? 'icon' : 'default'} className={cn( - 'w-full', - isCollapsed ? 'justify-center' : 'h-10 justify-start p-1 pr-2', + isCollapsed ? 'justify-center' : 'w-full h-10 justify-start p-1 pr-2', status === 'executing' && 'cursor-not-allowed opacity-50', )} disabled={status === 'executing'} diff --git a/apps/app/src/components/sidebar-collapse-button.tsx b/apps/app/src/components/sidebar-collapse-button.tsx index 00ab06bc6..0b841b0f3 100644 --- a/apps/app/src/components/sidebar-collapse-button.tsx +++ b/apps/app/src/components/sidebar-collapse-button.tsx @@ -1,46 +1,28 @@ 'use client'; -import { updateSidebarState } from '@/actions/sidebar'; -import { useSidebar } from '@/context/sidebar-context'; +import { useSidebar } from '@/components/ui/sidebar'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { ArrowLeftFromLine } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; -interface SidebarCollapseButtonProps { - isCollapsed: boolean; -} - -export function SidebarCollapseButton({ isCollapsed }: SidebarCollapseButtonProps) { - const { setIsCollapsed } = useSidebar(); - - const { execute } = useAction(updateSidebarState, { - onError: () => { - // Revert the optimistic update if the server action fails - setIsCollapsed(isCollapsed); - }, - }); - - const handleToggle = () => { - // Update local state immediately for responsive UI - setIsCollapsed(!isCollapsed); - // Update server state (cookie) in the background - execute({ isCollapsed: !isCollapsed }); - }; +export function SidebarCollapseButton() { + const { state, toggleSidebar } = useSidebar(); return ( - +
+ +
); } diff --git a/apps/app/src/components/sidebar-logo.tsx b/apps/app/src/components/sidebar-logo.tsx index 1b5ecd027..64a86889a 100644 --- a/apps/app/src/components/sidebar-logo.tsx +++ b/apps/app/src/components/sidebar-logo.tsx @@ -1,13 +1,10 @@ -import { cn } from '@comp/ui/cn'; import { Icons } from '@comp/ui/icons'; import Link from 'next/link'; -export function SidebarLogo({ isCollapsed }: { isCollapsed: boolean }) { +export function SidebarLogo() { return ( -
- - - -
+ + + ); } diff --git a/apps/app/src/components/sidebar.tsx b/apps/app/src/components/sidebar.tsx deleted file mode 100644 index d0fca391e..000000000 --- a/apps/app/src/components/sidebar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { getOrganizations } from '@/data/getOrganizations'; -import { cn } from '@comp/ui/cn'; -import type { Organization } from '@db'; -import { cookies } from 'next/headers'; -import { MainMenu } from './main-menu'; -import { OrganizationSwitcher } from './organization-switcher'; -import { SidebarCollapseButton } from './sidebar-collapse-button'; -import { SidebarLogo } from './sidebar-logo'; - -export async function Sidebar({ - organization, - collapsed = false, -}: { - organization: Organization | null; - collapsed?: boolean; -}) { - const cookieStore = await cookies(); - const isCollapsed = collapsed || cookieStore.get('sidebar-collapsed')?.value === 'true'; - const { organizations } = await getOrganizations(); - - return ( -
-
-
- -
-
- - -
-
-
- -
- -
-
- ); -} diff --git a/apps/app/src/components/ui/sidebar.tsx b/apps/app/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..ecf853a86 --- /dev/null +++ b/apps/app/src/components/ui/sidebar.tsx @@ -0,0 +1,669 @@ +'use client'; + +import { Slot } from '@radix-ui/react-slot'; +import { PanelLeftIcon } from 'lucide-react'; +import * as React from 'react'; + +import { useIsMobile } from '@/hooks/use-mobile'; +import { Button } from '@comp/ui/button'; +import { cn } from '@comp/ui/cn'; +import { Input } from '@comp/ui/input'; +import { Separator } from '@comp/ui/separator'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@comp/ui/sheet'; +import { Skeleton } from '@comp/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; + +const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '240px'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '80px'; +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 ( +