diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 0ded5c26f..ecbe09444 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -984,7 +984,8 @@ "avatar", "dropdown-menu", "skeleton", - "sidebar" + "sidebar", + "collapsible" ], "files": [ { "path": "registry/shell/shell.tsx", "type": "registry:ui" }, @@ -1019,10 +1020,6 @@ "path": "registry/shell/shell-animations.ts", "type": "registry:ui" }, - { - "path": "registry/shell/shell-nav-item.tsx", - "type": "registry:ui" - }, { "path": "registry/shell/shell-minimal-company.tsx", "type": "registry:ui" diff --git a/apps/apollo-vertex/registry/shell/shell-company.tsx b/apps/apollo-vertex/registry/shell/shell-company.tsx index 7a75a92b5..c3508e90e 100644 --- a/apps/apollo-vertex/registry/shell/shell-company.tsx +++ b/apps/apollo-vertex/registry/shell/shell-company.tsx @@ -4,6 +4,7 @@ import { PanelLeft } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent, @@ -24,8 +25,6 @@ interface CompanyProps { companyName: string; productName: string; companyLogo?: CompanyLogo; - isCollapsed: boolean; - toggleCollapse: () => void; sidebarHovered?: boolean; } @@ -105,11 +104,11 @@ export const Company = ({ companyName, productName, companyLogo, - isCollapsed, - toggleCollapse, sidebarHovered = false, }: CompanyProps) => { const { t } = useTranslation(); + const { state, toggleSidebar } = useSidebar(); + const isCollapsed = state === "collapsed"; const isCustomLogo = companyLogo?.isCustom ?? false; const logoBgClass = isCustomLogo @@ -135,7 +134,7 @@ export const Company = ({ ) : ( iconElement @@ -188,7 +187,7 @@ export const Company = ({ diff --git a/apps/apollo-vertex/registry/shell/shell-layout.tsx b/apps/apollo-vertex/registry/shell/shell-layout.tsx index d0056767b..bdd2216b5 100644 --- a/apps/apollo-vertex/registry/shell/shell-layout.tsx +++ b/apps/apollo-vertex/registry/shell/shell-layout.tsx @@ -1,8 +1,22 @@ -import type { PropsWithChildren } from "react"; +import { useLocalStorage } from "@mantine/hooks"; +import { type CSSProperties, type PropsWithChildren, useId } from "react"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; import type { CompanyLogo, ShellNavItem } from "./shell"; -import { Sidebar } from "./shell-sidebar"; +import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; +import { ShellSidebar } from "./shell-sidebar"; import { useTheme } from "./shell-theme-provider"; +/* oxlint-disable typescript-eslint(no-unsafe-type-assertion) -- CSS custom properties not in React.CSSProperties */ +const SIDEBAR_WIDTHS = { + "--sidebar-width": "280px", + "--sidebar-width-icon": "4rem", +} as CSSProperties; +/* oxlint-enable typescript-eslint(no-unsafe-type-assertion) */ + const GRADIENT_BLUR = "blur(149.643px)"; interface ShellLayoutProps { @@ -14,6 +28,8 @@ interface ShellLayoutProps { } function DarkGradientBackground() { + const filterId = useId(); + return ( {/* Base directional wash */} @@ -74,7 +90,7 @@ function DarkGradientBackground() { className="absolute inset-0 w-full h-full opacity-[0.04]" > - + - + ); @@ -150,7 +166,7 @@ function LightGradientBackground() { function GradientBackground() { const theme = useTheme(); - if (theme.theme === "dark") { + if (theme.resolvedTheme === "dark") { return ; } return ; @@ -164,12 +180,17 @@ export function ShellLayout({ companyLogo, navItems, }: PropsWithChildren) { + const [isCollapsed, setIsCollapsed] = useLocalStorage({ + key: SIDEBAR_COLLAPSED_KEY, + defaultValue: false, + }); + if (variant === "minimal") { return ( - + setIsCollapsed(!open)} + style={SIDEBAR_WIDTHS} + className="relative isolate h-screen overflow-hidden bg-background dark:bg-sidebar" + > - - - + + + + + {children} - - + + ); } diff --git a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx b/apps/apollo-vertex/registry/shell/shell-nav-item.tsx deleted file mode 100644 index ad22ad73b..000000000 --- a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Link, useLocation } from "@tanstack/react-router"; -import { useLocalStorage } from "@mantine/hooks"; -import { AnimatePresence, motion } from "framer-motion"; -import type { LucideIcon } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { - fastFadeTransition, - iconHoverScale, - textFadeVariants, -} from "./shell-animations"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; -import { Text } from "./shell-text"; -import type { TranslationKey } from "./shell-translation-key"; - -interface NavItemProps { - to: string; - icon: LucideIcon; - label: TranslationKey; -} - -export const NavItem = ({ to, icon: Icon, label }: NavItemProps) => { - const [isCollapsed] = useLocalStorage({ - key: SIDEBAR_COLLAPSED_KEY, - defaultValue: false, - }); - const { pathname } = useLocation(); - const isActive = pathname === to || pathname.startsWith(`${to}/`); - - const linkContent = ( - - - - - - {!isCollapsed && ( - - - - )} - - - ); - - if (isCollapsed) { - return ( - - - {linkContent} - - - - - - ); - } - - return linkContent; -}; diff --git a/apps/apollo-vertex/registry/shell/shell-sidebar.tsx b/apps/apollo-vertex/registry/shell/shell-sidebar.tsx index 2fff7d172..d3a17ed94 100644 --- a/apps/apollo-vertex/registry/shell/shell-sidebar.tsx +++ b/apps/apollo-vertex/registry/shell/shell-sidebar.tsx @@ -1,16 +1,43 @@ -import { useLocalStorage } from "@mantine/hooks"; -import { motion } from "framer-motion"; +import { Link, useLocation } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import type { CompanyLogo, ShellNavItem } from "./shell"; -import { sidebarSpring } from "./shell-animations"; +import { fastFadeTransition, textFadeVariants } from "./shell-animations"; import { Company } from "./shell-company"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; import { MinimalCompany } from "./shell-minimal-company"; import { MinimalNavItem } from "./shell-minimal-nav-item"; -import { NavItem } from "./shell-nav-item"; +import { Text } from "./shell-text"; import { UserProfile } from "./shell-user-profile"; -interface SidebarProps { +const activeNavClass = + "text-sidebar-foreground/85 hover:text-sidebar-foreground data-[active=true]:text-primary-700 dark:data-[active=true]:text-primary-400 data-[active=true]:font-semibold data-[active=true]:bg-primary-100/40 dark:data-[active=true]:bg-primary-900/30"; +const navButtonClass = `font-medium ${activeNavClass}`; +const subButtonClass = activeNavClass; + +interface ShellSidebarProps { companyName: string; productName: string; variant?: "minimal"; @@ -18,21 +45,13 @@ interface SidebarProps { navItems: ShellNavItem[]; } -export const Sidebar = ({ +export const ShellSidebar = ({ companyName, productName, variant, companyLogo, navItems, -}: SidebarProps) => { - const [isCollapsed, setIsCollapsed] = useLocalStorage({ - key: SIDEBAR_COLLAPSED_KEY, - defaultValue: false, - }); - const toggleCollapse = () => setIsCollapsed((prev) => !prev); - - const sidebarWidth = isCollapsed ? "w-16" : "w-[280px]"; - +}: ShellSidebarProps) => { if (variant === "minimal") { return ( @@ -66,41 +85,244 @@ export const Sidebar = ({ } return ( - + ); +}; + +interface SidebarNavProps { + companyName: string; + productName: string; + companyLogo?: CompanyLogo; + navItems: ShellNavItem[]; +} + +function SidebarNav({ + companyName, + productName, + companyLogo, + navItems, +}: SidebarNavProps) { + const { t } = useTranslation(); + const { state, toggleSidebar } = useSidebar(); + const { pathname } = useLocation(); + const isCollapsed = state === "collapsed"; + const [sidebarHovered, setSidebarHovered] = useState(false); + + const handleMouseEnter = () => { + if (isCollapsed) setSidebarHovered(true); + }; + + const handleMouseLeave = () => { + setSidebarHovered(false); + }; + + const [expandedItems, setExpandedItems] = useState>(() => { + const expanded = new Set(); + for (const item of navItems) { + if (item.subItems?.some((sub) => pathname.startsWith(sub.path))) { + expanded.add(item.path); + } + } + return expanded; + }); + + useEffect(() => { + const expanded = new Set(); + for (const item of navItems) { + if (item.subItems?.some((sub) => pathname.startsWith(sub.path))) { + expanded.add(item.path); + } + } + setExpandedItems(expanded); + }, [pathname, navItems]); + + const handleMenuClick = (path: string) => { + if (isCollapsed) { + toggleSidebar(); + setExpandedItems((prev) => new Set(prev).add(path)); + } else { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + } + }; + + const isActive = (path: string) => { + if (path === "/") { + return pathname === "/"; + } + return pathname === path || pathname.startsWith(`${path}/`); + }; + + const isParentActive = (item: ShellNavItem) => { + return item.subItems?.some((sub) => isActive(sub.path)) ?? false; + }; + + const getTooltipText = (label: ShellNavItem["label"]): string => { + if (typeof label === "string") return t(label); + return t(label.i18nKey, label.values); + }; + + return ( + - - - {navItems.map((item) => ( - - ))} - - + + + + + + + + + {navItems.map((item) => { + const Icon = item.icon; + const active = isActive(item.path); + const parentActive = isParentActive(item); + const isExpanded = expandedItems.has(item.path); + + if (item.subItems && item.subItems.length > 0) { + const showParentActive = isCollapsed && parentActive; + + return ( + handleMenuClick(item.path)} + className="group/collapsible" + > + + + + + + {!isCollapsed && ( + + + + )} + + + {!isCollapsed && ( + + + + )} + + + + + + {item.subItems.map((subItem) => { + const subActive = isActive(subItem.path); + return ( + + + + + + + + ); + })} + + + + + ); + } + + return ( + + + + + + {!isCollapsed && ( + + + + )} + + + + + ); + })} + + + + + + - + + - + ); -}; +} diff --git a/apps/apollo-vertex/registry/shell/shell.tsx b/apps/apollo-vertex/registry/shell/shell.tsx index 53d3f15d9..b01d6392d 100644 --- a/apps/apollo-vertex/registry/shell/shell.tsx +++ b/apps/apollo-vertex/registry/shell/shell.tsx @@ -15,10 +15,16 @@ export interface CompanyLogo { isCustom?: boolean; } +export interface ShellSubNavItem { + path: string; + label: TranslationKey; +} + export interface ShellNavItem { path: string; label: TranslationKey; icon: LucideIcon; + subItems?: ShellSubNavItem[]; } export interface ApolloShellProps extends PropsWithChildren {