From 84117e6821a6ff8eafc0a0ac31b9b0aa266b6f5f Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Thu, 18 Dec 2025 14:38:35 -0300 Subject: [PATCH] feat(ui): implementa sidebar colapsavel com icones e tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Muda collapsible de offcanvas para icon na sidebar - Adiciona tooltips aos itens de menu quando colapsado - Itens com submenu mostram mini-menu no tooltip - Logo mostra apenas icone quando colapsado - NavUser mostra apenas avatar quando colapsado - Adiciona separadores entre secoes quando colapsado - Centraliza icones horizontalmente no modo colapsado - Persiste estado da sidebar via cookie entre navegacoes - Corrige hydration mismatch com sincronizacao pos-hidratacao - Desabilita transicoes durante sincronizacao inicial - Remove bolinha do tooltip e ajusta espacamento - Corrige redirecionamento ao resetar dispositivo no Tauri 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/app-sidebar.tsx | 208 ++++++++++++++++--------- src/components/nav-user.tsx | 22 ++- src/components/portal/portal-shell.tsx | 12 +- src/components/sidebar-brand.tsx | 91 ++++++++--- src/components/ui/sidebar.tsx | 120 ++++++++------ src/components/ui/tooltip.tsx | 10 +- 6 files changed, 305 insertions(+), 158 deletions(-) diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 6c99ebd..6065514 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -44,7 +44,14 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, + SidebarSeparator, + useSidebar, } from "@/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { Skeleton } from "@/components/ui/skeleton" import { NavUser } from "@/components/nav-user" import { useAuth } from "@/lib/auth-client" @@ -151,9 +158,11 @@ const navigation: NavigationGroup[] = [ }, ] -export function AppSidebar({ ...props }: React.ComponentProps) { +export function AppSidebar(props: React.ComponentProps) { const pathname = usePathname() const { session, isLoading, isAdmin, isStaff, role } = useAuth() + const { state: sidebarState } = useSidebar() + const isCollapsed = sidebarState === "collapsed" const [isHydrated, setIsHydrated] = React.useState(false) const canAccess = React.useCallback( (requiredRole?: NavRoleRequirement) => { @@ -221,7 +230,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { if (!isHydrated) { return ( - + @@ -247,7 +256,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { } return ( - + ) { /> - {navigation.map((group) => { + {navigation.map((group, groupIndex) => { if (!canAccess(group.requiredRole)) return null const visibleItems = group.items.filter((item) => !item.hidden && canAccess(item.requiredRole)) if (visibleItems.length === 0) return null - return ( - - {group.title} - - - {visibleItems.map((item) => { - if (item.children && item.children.length > 0) { - const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole)) - const isExpanded = expanded.has(item.title) - const isChildActive = childItems.some((child) => isActive(child)) - const parentActive = isChildActive - const isToggleOnly = true // Todos os menus com filhos expandem ao clicar, nao navegam - return ( - - - {isToggleOnly ? ( + // Verifica se deve mostrar separador (quando colapsado e nao e o primeiro grupo) + const showSeparator = isCollapsed && groupIndex > 0 + + return ( + + {showSeparator && } + + {!isCollapsed && {group.title}} + + + {visibleItems.map((item) => { + if (item.children && item.children.length > 0) { + const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole)) + const isExpanded = expanded.has(item.title) + const isChildActive = childItems.some((child) => isActive(child)) + + // Quando colapsado, mostra tooltip com submenu + if (isCollapsed) { + return ( + + + + + + {item.icon ? : null} + + + + +
+
+

{item.title}

+
+
+ {childItems.map((child) => ( + + {child.icon ? : null} + {child.title} + + ))} +
+
+
+
+
+ ) + } + + // Modo expandido - comportamento normal + return ( + + - ) : ( - - - {item.icon ? : null} - {item.title} - { - event.preventDefault() - event.stopPropagation() - toggleExpanded(item.title) - }} - className={cn( - "absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700", - isExpanded && "rotate-180" - )} - > - - - - - )} - - {isExpanded - ? childItems.map((child) => ( - - - - {child.icon ? : null} - {child.title} - - - - )) - : null} - - ) - } +
+ {isExpanded + ? childItems.map((child) => ( + + + + {child.icon ? : null} + {child.title} + + + + )) + : null} +
+ ) + } - return ( - - - - {item.icon ? : null} - {item.title} - - - - ) - })} -
-
-
+ // Item simples (sem filhos) + if (isCollapsed) { + return ( + + + + + + {item.icon ? : null} + + + + + {item.title} + + + + ) + } + + return ( + + + + {item.icon ? : null} + {item.title} + + + + ) + })} + + + + ) })}
diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 1b595b7..2868f11 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -31,6 +31,7 @@ import { useSidebar, } from "@/components/ui/sidebar" import { signOut, useAuth } from "@/lib/auth-client" +import { cn } from "@/lib/utils" type NavUserProps = { user?: { @@ -42,7 +43,8 @@ type NavUserProps = { export function NavUser({ user }: NavUserProps) { const normalizedUser = user ?? { name: null, email: null, avatarUrl: null } - const { isMobile } = useSidebar() + const { isMobile, state } = useSidebar() + const isCollapsed = state === "collapsed" const router = useRouter() const [isSigningOut, setIsSigningOut] = useState(false) const [isDesktopShell, setIsDesktopShell] = useState(false) @@ -96,19 +98,25 @@ export function NavUser({ user }: NavUserProps) { - + {initials} -
- {displayName} - +
+ {displayName} + {displayEmail}
- + { - console.log("[PortalShell] Token foi revogado - redirecionando para login") + console.log("[PortalShell] Token foi revogado - redirecionando para tela de registro") toast.error("Este dispositivo foi resetado. Faça login novamente.") + // Se estiver rodando dentro do Tauri, navega para a URL do app para voltar à tela de registro + const isTauri = typeof window !== "undefined" && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__) + if (isTauri) { + // URL do app Tauri - em producao usa tauri.localhost, em dev usa localhost:1420 + const isDev = process.env.NODE_ENV === "development" + const tauriUrl = isDev ? "http://localhost:1420/" : "http://tauri.localhost/" + console.log("[PortalShell] Detectado ambiente Tauri, navegando para:", tauriUrl) + window.location.href = tauriUrl + return + } router.replace("/login") }, [router]) diff --git a/src/components/sidebar-brand.tsx b/src/components/sidebar-brand.tsx index 48f8809..032e1ba 100644 --- a/src/components/sidebar-brand.tsx +++ b/src/components/sidebar-brand.tsx @@ -6,7 +6,14 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from "@/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" interface SidebarBrandProps { logoSrc: string @@ -16,33 +23,69 @@ interface SidebarBrandProps { } export function SidebarBrand({ logoSrc, logoAlt, title, subtitle }: SidebarBrandProps) { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + + const brandContent = ( +
+
+ {logoAlt} +
+
+ {title} + + {subtitle} + +
+
+ ) + + const button = ( + + {brandContent} + + ) + return ( - -
-
- {logoAlt} -
-
- {title} - - {subtitle} - -
-
-
+ {isCollapsed ? ( + + + {button} + + +

{title}

+

{subtitle}

+
+
+ ) : ( + button + )}
) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 6dc820f..a13633e 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -27,9 +27,9 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "clamp(12rem, 15vw + 1.5rem, 16.5rem)" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_WIDTH = "clamp(12rem, 15vw + 1.5rem, 16.5rem)" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "4rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" type SidebarContextProps = { @@ -40,6 +40,7 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void + isHydrated: boolean } const SidebarContext = React.createContext(null) @@ -68,11 +69,31 @@ function SidebarProvider({ }) { const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) + const [isHydrated, setIsHydrated] = 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 + + // Sync state from cookie after hydration to avoid hydration mismatch + React.useEffect(() => { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) + if (cookie) { + const cookieValue = cookie.split("=")[1] === "true" + if (cookieValue !== _open) { + _setOpen(cookieValue) + } + } + // Mark as hydrated after a small delay to allow state to settle without animation + requestAnimationFrame(() => { + setIsHydrated(true) + }) + // Only run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value @@ -122,16 +143,18 @@ function SidebarProvider({ openMobile, setOpenMobile, toggleSidebar, + isHydrated, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, isHydrated] ) return ( -
) { - const { toggleSidebar } = useSidebar() - const [hydrated, setHydrated] = React.useState(false) - - React.useEffect(() => { - setHydrated(true) - }, []) - - return ( -