feat(ui): implementa sidebar colapsavel com icones e tooltips
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 9s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m42s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m20s

- 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 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-18 14:38:35 -03:00
parent 826b376dd3
commit 84117e6821
6 changed files with 305 additions and 158 deletions

View file

@ -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<typeof Sidebar>) {
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
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<typeof Sidebar>) {
if (!isHydrated) {
return (
<Sidebar {...props}>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader className="pt-4">
<Skeleton className="h-14 w-full rounded-lg" />
</SidebarHeader>
@ -247,7 +256,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
return (
<Sidebar {...props}>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader className="pt-4">
<SidebarBrand
logoSrc="/logo-raven.png"
@ -257,13 +266,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
/>
</SidebarHeader>
<SidebarContent>
{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
// Verifica se deve mostrar separador (quando colapsado e nao e o primeiro grupo)
const showSeparator = isCollapsed && groupIndex > 0
return (
<React.Fragment key={group.title}>
{showSeparator && <SidebarSeparator className="my-2" />}
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
{!isCollapsed && <SidebarGroupLabel>{group.title}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{visibleItems.map((item) => {
@ -271,13 +286,58 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
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
// Quando colapsado, mostra tooltip com submenu
if (isCollapsed) {
return (
<SidebarMenuItem key={item.title}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
isActive={isChildActive}
className="font-medium"
>
<Link href={childItems[0]?.url ?? item.url} className="gap-2">
{item.icon ? <item.icon className="size-5" /> : null}
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="bg-sidebar border-sidebar-border p-0 shadow-lg"
>
<div className="flex flex-col min-w-[180px]">
<div className="border-b border-sidebar-border px-3 py-2">
<p className="font-semibold text-sm text-sidebar-foreground">{item.title}</p>
</div>
<div className="py-1">
{childItems.map((child) => (
<Link
key={child.title}
href={child.url}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors",
isActive(child) && "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
)}
>
{child.icon ? <child.icon className="size-3.5 text-sidebar-foreground/70" /> : null}
<span>{child.title}</span>
</Link>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
)
}
// Modo expandido - comportamento normal
return (
<React.Fragment key={item.title}>
<SidebarMenuItem>
{isToggleOnly ? (
<button
type="button"
className={cn(
@ -298,29 +358,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<ChevronDown className="size-3" />
</span>
</button>
) : (
<SidebarMenuButton asChild isActive={parentActive}>
<Link href={item.url} className={cn("gap-2", "relative pr-7") }>
{item.icon ? <item.icon className="size-4" /> : null}
<span className="flex-1">{item.title}</span>
<span
role="button"
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
onClick={(event) => {
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"
)}
>
<ChevronDown className="size-3" />
</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
{isExpanded
? childItems.map((child) => (
@ -338,6 +375,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)
}
// Item simples (sem filhos)
if (isCollapsed) {
return (
<SidebarMenuItem key={item.title}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild isActive={isActive(item)} className="font-medium">
<Link href={item.url} className="gap-2">
{item.icon ? <item.icon className="size-5" /> : null}
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="bg-sidebar border-sidebar-border text-sidebar-foreground shadow-lg"
>
{item.title}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
)
}
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item)} className="font-medium">
@ -352,6 +413,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</React.Fragment>
)
})}
</SidebarContent>

View file

@ -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) {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground transition-all duration-200 ease-linear"
>
<Avatar className="h-8 w-8 rounded-lg">
<Avatar className="h-8 w-8 shrink-0 rounded-lg transition-all duration-200 ease-linear">
<AvatarImage src={normalizedUser.avatarUrl ?? undefined} alt={displayName} />
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-700 font-medium">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="text-muted-foreground truncate text-xs">
<div className={cn(
"grid flex-1 text-left text-sm leading-tight transition-all duration-200 ease-linear overflow-hidden",
isCollapsed ? "w-0 opacity-0" : "w-auto opacity-100"
)}>
<span className="truncate font-medium whitespace-nowrap">{displayName}</span>
<span className="text-muted-foreground truncate text-xs whitespace-nowrap">
{displayEmail}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
<IconDotsVertical className={cn(
"ml-auto size-4 transition-all duration-200 ease-linear",
isCollapsed ? "w-0 opacity-0" : "w-4 opacity-100"
)} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -41,8 +41,18 @@ export function PortalShell({ children }: PortalShellProps) {
}, [])
const handleTokenRevoked = useCallback(() => {
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])

View file

@ -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) {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
size="lg"
className="h-auto cursor-default select-none hover:bg-transparent hover:text-inherit active:bg-transparent active:text-inherit focus-visible:ring-0"
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
const brandContent = (
<div className="flex items-center gap-3 transition-all duration-200 ease-linear">
<div className={cn(
"flex shrink-0 items-center justify-center transition-all duration-200 ease-linear",
isCollapsed ? "size-9" : "size-8"
)}>
<Image
src={logoSrc}
alt={logoAlt}
width={48}
height={48}
className="h-12 w-12 object-contain"
width={36}
height={36}
className={cn(
"object-contain transition-all duration-200 ease-linear",
isCollapsed ? "size-9" : "size-8"
)}
priority
/>
</div>
<div className="flex flex-col items-start gap-1 leading-none">
<span className="text-lg font-semibold">{title}</span>
<div className={cn(
"flex flex-col items-start gap-1 leading-none transition-all duration-200 ease-linear overflow-hidden",
isCollapsed ? "w-0 opacity-0" : "w-auto opacity-100"
)}>
<span className="text-lg font-semibold whitespace-nowrap">{title}</span>
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
{subtitle}
</span>
</div>
</div>
)
const button = (
<SidebarMenuButton
asChild
size="lg"
className="h-auto cursor-default select-none hover:bg-transparent hover:text-inherit active:bg-transparent active:text-inherit focus-visible:ring-0"
>
{brandContent}
</SidebarMenuButton>
)
return (
<SidebarMenu>
<SidebarMenuItem>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="bg-sidebar border-sidebar-border text-sidebar-foreground shadow-lg"
>
<p className="font-semibold">{title}</p>
<p className="text-xs text-sidebar-foreground/70">{subtitle}</p>
</TooltipContent>
</Tooltip>
) : (
button
)}
</SidebarMenuItem>
</SidebarMenu>
)

View file

@ -29,7 +29,7 @@ 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_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<SidebarContextProps | null>(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,8 +143,9 @@ function SidebarProvider({
openMobile,
setOpenMobile,
toggleSidebar,
isHydrated,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, isHydrated]
)
return (
@ -132,6 +154,7 @@ function SidebarProvider({
<div
suppressHydrationWarning
data-slot="sidebar-wrapper"
data-hydrated={isHydrated ? "true" : "false"}
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
@ -141,6 +164,7 @@ function SidebarProvider({
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
!isHydrated && "[&_*]:!transition-none",
className
)}
{...props}
@ -466,7 +490,11 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn(
"flex w-full min-w-0 flex-col gap-1",
"group-data-[collapsible=icon]:items-center",
className
)}
{...props}
/>
)

View file

@ -36,7 +36,7 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 20,
sideOffset = 8,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
@ -46,16 +46,12 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 w-fit origin-(--radix-tooltip-content-transform-origin) overflow-visible rounded-md px-3 py-1.5 text-xs text-balance group",
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) overflow-hidden rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<span
aria-hidden
className="pointer-events-none absolute z-50 size-2 rounded-full bg-neutral-900 group-data-[side=top]:left-1/2 group-data-[side=top]:top-full group-data-[side=top]:-translate-x-1/2 group-data-[side=top]:mt-1 group-data-[side=bottom]:left-1/2 group-data-[side=bottom]:bottom-full group-data-[side=bottom]:-translate-x-1/2 group-data-[side=bottom]:mb-1 group-data-[side=left]:left-full group-data-[side=left]:top-1/2 group-data-[side=left]:-translate-y-1/2 group-data-[side=left]:ml-1 group-data-[side=right]:right-full group-data-[side=right]:top-1/2 group-data-[side=right]:-translate-y-1/2 group-data-[side=right]:mr-1"
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)