refactor(nav): remover sidebar, unificar navegação na navbar

Componentes da sidebar lateral (app-sidebar, nav-main, nav-secondary,
nav-user, nav-link), sidebar.tsx e use-mobile.ts removidos.
Barrel exports órfãos de shared/hooks, shared/components/providers,
shared/lib/schemas e shared/lib/types também removidos.
Navbar recebe ajustes menores de markup e acessibilidade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:52:07 +00:00
parent ba05985725
commit 5d84ae928a
19 changed files with 25 additions and 1382 deletions

View File

@@ -25,10 +25,10 @@ export function NavDropdown({ items }: NavDropdownProps) {
href={item.href} href={item.href}
preservePeriod={item.preservePeriod} preservePeriod={item.preservePeriod}
className={cn( className={cn(
"flex items-center gap-3 rounded-sm px-2 py-2 text-sm transition-colors", "flex items-center gap-3 rounded-sm px-2 py-3 text-sm transition-colors",
isActive isActive
? "bg-accent text-foreground" ? "border-primary bg-accent text-foreground"
: "text-foreground hover:bg-accent", : "border-transparent text-foreground hover:bg-accent",
)} )}
> >
<span <span

View File

@@ -29,9 +29,10 @@ import { NavPill } from "./nav-pill";
import { MobileTools, NavToolsDropdown } from "./nav-tools"; import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass = const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!"; "h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10! dark:text-white/75! dark:hover:text-white! dark:hover:bg-white/10! dark:focus:text-white! dark:focus:bg-white/10! dark:focus-visible:ring-white/20! dark:data-[state=open]:text-white! dark:data-[state=open]:bg-white/10!";
const triggerActiveClass = "bg-black/15! text-black!"; const triggerActiveClass =
"bg-black/15! text-black! dark:bg-white/15! dark:text-white!";
export function NavMenu() { export function NavMenu() {
const pathname = usePathname(); const pathname = usePathname();

View File

@@ -26,7 +26,7 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
className={cn( className={cn(
buttonVariants({ variant: "navbar", size: "sm" }), buttonVariants({ variant: "navbar", size: "sm" }),
"capitalize", "capitalize",
isActive && "bg-black/15 text-black", isActive && "bg-black/15 text-black dark:bg-white/15 dark:text-white",
)} )}
> >
{children} {children}

View File

@@ -5,7 +5,7 @@ import { usePrivacyMode } from "@/shared/components/providers/privacy-provider";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
const itemClass = const itemClass =
"flex w-full items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer"; "flex w-full items-center gap-2.5 rounded-sm px-2 py-3 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer";
type NavToolsDropdownProps = { type NavToolsDropdownProps = {
onOpenCalculator: () => void; onOpenCalculator: () => void;
@@ -22,7 +22,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
<RiCalculatorLine className="size-4" /> <RiCalculatorLine className="size-4" />
</span> </span>
<span className="flex flex-col flex-1 text-left"> <span className="flex flex-col flex-1 text-left">
<span className="font-medium">Calculadora</span> <span className="font-semibold">Calculadora</span>
<span className="text-xs text-muted-foreground lowercase"> <span className="text-xs text-muted-foreground lowercase">
Faça cálculos rápidos Faça cálculos rápidos
</span> </span>
@@ -39,7 +39,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
)} )}
</span> </span>
<span className="flex flex-col flex-1 text-left"> <span className="flex flex-col flex-1 text-left">
<span className="font-medium">Privacidade</span> <span className="font-semibold">Privacidade</span>
<span className="text-xs text-muted-foreground lowercase"> <span className="text-xs text-muted-foreground lowercase">
Oculta valores na tela Oculta valores na tela
</span> </span>

View File

@@ -16,15 +16,21 @@ export function NavbarShell({
return ( return (
<header <header
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary`} className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary border-b dark:bg-card dark:border-b-border/60`}
> >
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4"> <div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
{logoHref ? ( {logoHref ? (
<Link href={logoHref} className="shrink-0"> <Link href={logoHref} className="shrink-0">
<Logo variant="compact" invertTextOnDark={false} /> <Logo
variant="compact"
iconClassName="dark:brightness-100 dark:saturate-100"
/>
</Link> </Link>
) : ( ) : (
<Logo variant="compact" invertTextOnDark={false} /> <Logo
variant="compact"
iconClassName="dark:brightness-100 dark:saturate-100"
/>
)} )}
{children} {children}
</div> </div>

View File

@@ -34,9 +34,11 @@ export function NotificationBellTrigger({
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative shadow-none transition-all duration-200", "group relative shadow-none transition-all duration-200",
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20", "hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:border-white/20 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
"data-[state=open]:bg-black/10 data-[state=open]:text-black", "data-[state=open]:bg-black/10 data-[state=open]:text-black dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white",
hasAnySourceItems ? "text-black" : "text-black/75", hasAnySourceItems
? "text-black dark:text-white"
: "text-black/75 dark:text-white/75",
)} )}
> >
<RiNotification2Line <RiNotification2Line

View File

@@ -8,7 +8,7 @@ import {
markDashboardNotificationAsReadAction, markDashboardNotificationAsReadAction,
markDashboardNotificationAsUnreadAction, markDashboardNotificationAsUnreadAction,
unarchiveDashboardNotificationAction, unarchiveDashboardNotificationAction,
} from "@/features/dashboard/notifications-actions"; } from "@/features/dashboard/notifications/notifications-actions";
import type { import type {
NotificationActionState, NotificationActionState,
NotificationBellProps, NotificationBellProps,

View File

@@ -1,81 +0,0 @@
"use client";
import * as React from "react";
import { Logo } from "@/shared/components/logo";
import { NavMain } from "@/shared/components/navigation/sidebar/nav-main";
import { NavSecondary } from "@/shared/components/navigation/sidebar/nav-secondary";
import { NavUser } from "@/shared/components/navigation/sidebar/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/shared/components/ui/sidebar";
import { createSidebarNavData, type PagadorLike } from "./nav-link";
type AppUser = {
id: string;
name: string;
email: string;
image: string | null;
};
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
user: AppUser;
pagadorAvatarUrl: string | null;
pagadores: PagadorLike[];
preLancamentosCount?: number;
}
export function AppSidebar({
user,
pagadorAvatarUrl,
pagadores,
preLancamentosCount = 0,
...props
}: AppSidebarProps) {
if (!user) {
throw new Error("AppSidebar requires a user but received undefined.");
}
const navigation = React.useMemo(
() => createSidebarNavData({ pagadores, preLancamentosCount }),
[pagadores, preLancamentosCount],
);
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:px-1.5! hover:bg-transparent active:bg-transparent pt-8 py-6 justify-center hover:scale-120 scale-110 transition-all duration-200"
>
<a href="/dashboard">
<LogoContent />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain sections={navigation.navMain} />
<NavSecondary items={navigation.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</SidebarFooter>
</Sidebar>
);
}
function LogoContent() {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
return <Logo variant={isCollapsed ? "small" : "full"} />;
}

View File

@@ -1,185 +0,0 @@
import {
type RemixiconComponentType,
RiArrowLeftRightLine,
RiAtLine,
RiBankCard2Line,
RiBankLine,
RiCalendarEventLine,
RiDashboardLine,
RiFileChartLine,
RiFundsLine,
RiGroupLine,
RiPriceTag3Line,
RiSecurePaymentLine,
RiSettings2Line,
RiSparklingLine,
RiTodoLine,
} from "@remixicon/react";
export type SidebarSubItem = {
title: string;
url: string;
avatarUrl?: string | null;
isShared?: boolean;
key?: string;
icon?: RemixiconComponentType;
badge?: number;
};
export type SidebarItem = {
title: string;
url: string;
icon: RemixiconComponentType;
isActive?: boolean;
items?: SidebarSubItem[];
};
export type SidebarSection = {
title: string;
items: SidebarItem[];
};
export type SidebarNavData = {
navMain: SidebarSection[];
navSecondary: {
title: string;
url: string;
icon: RemixiconComponentType;
}[];
};
export interface PagadorLike {
id: string;
name: string | null;
avatarUrl: string | null;
canEdit?: boolean;
}
export interface SidebarNavOptions {
pagadores: PagadorLike[];
preLancamentosCount?: number;
}
export function createSidebarNavData(
options: SidebarNavOptions,
): SidebarNavData {
const { pagadores, preLancamentosCount = 0 } = options;
const pagadorItems = pagadores
.map((pagador) => ({
title: pagador.name?.trim().length
? pagador.name.trim()
: "Pagador sem nome",
url: `/payers/${pagador.id}`,
key: pagador.canEdit ? pagador.id : `${pagador.id}-shared`,
isShared: !pagador.canEdit,
avatarUrl: pagador.avatarUrl,
}))
.sort((a, b) =>
a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" }),
);
const pagadorItemsWithHistory: SidebarSubItem[] = pagadorItems;
return {
navMain: [
{
title: "Gestão Financeira",
items: [
{
title: "Dashboard",
url: "/dashboard",
icon: RiDashboardLine,
},
{
title: "Lançamentos",
url: "/transactions",
icon: RiArrowLeftRightLine,
items: [
{
title: "Pré-Lançamentos",
url: "/inbox",
key: "pre-lancamentos",
icon: RiAtLine,
badge:
preLancamentosCount > 0 ? preLancamentosCount : undefined,
},
],
},
{
title: "Calendário",
url: "/calendar",
icon: RiCalendarEventLine,
},
{
title: "Cartões",
url: "/cards",
icon: RiBankCard2Line,
},
{
title: "Contas",
url: "/accounts",
icon: RiBankLine,
},
{
title: "Orçamentos",
url: "/budgets",
icon: RiFundsLine,
},
],
},
{
title: "Organização",
items: [
{
title: "Pagadores",
url: "/payers",
icon: RiGroupLine,
items: pagadorItemsWithHistory,
},
{
title: "Categorias",
url: "/categories",
icon: RiPriceTag3Line,
},
{
title: "Anotações",
url: "/notes",
icon: RiTodoLine,
},
],
},
{
title: "Análise",
items: [
{
title: "Insights",
url: "/insights",
icon: RiSparklingLine,
},
{
title: "Tendências",
url: "/reports/category-trends",
icon: RiFileChartLine,
},
{
title: "Uso de Cartões",
url: "/reports/card-usage",
icon: RiBankCard2Line,
},
{
title: "Análise de Parcelas",
url: "/reports/installment-analysis",
icon: RiSecurePaymentLine,
},
],
},
],
navSecondary: [
{
title: "Ajustes",
url: "/settings",
icon: RiSettings2Line,
},
],
};
}

View File

@@ -1,212 +0,0 @@
"use client";
import {
type RemixiconComponentType,
RiArrowRightSLine,
RiUserSharedLine,
} from "@remixicon/react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/shared/components/ui/avatar";
import { Badge } from "@/shared/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/shared/components/ui/sidebar";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
type NavItem = {
title: string;
url: string;
icon: RemixiconComponentType;
isActive?: boolean;
items?: {
title: string;
url: string;
avatarUrl?: string | null;
isShared?: boolean;
key?: string;
icon?: RemixiconComponentType;
badge?: number;
}[];
};
type NavSection = {
title: string;
items: NavItem[];
};
const MONTH_PERIOD_PARAM = "periodo";
const PERIOD_AWARE_PATHS = new Set(["/dashboard", "/transactions", "/budgets"]);
export function NavMain({ sections }: { sections: NavSection[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const periodParam = searchParams.get(MONTH_PERIOD_PARAM);
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const isLinkActive = (url: string) => {
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
};
const buildHrefWithPeriod = (url: string) => {
if (!periodParam) {
return url;
}
const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedRawPathname =
rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1)
: rawPathname;
if (!PERIOD_AWARE_PATHS.has(normalizedRawPathname)) {
return url;
}
const params = new URLSearchParams(existingSearch);
params.set(MONTH_PERIOD_PARAM, periodParam);
const queryString = params.toString();
return queryString
? `${normalizedRawPathname}?${queryString}`
: normalizedRawPathname;
};
const activeLinkClasses =
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!";
return (
<>
{sections.map((section, index) => (
<SidebarGroup key={section.title}>
<SidebarGroupLabel className="text-xs text-muted-foreground/60">
{section.title}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map((item) => {
const itemIsActive = isLinkActive(item.url);
return (
<Collapsible key={item.title} asChild>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={itemIsActive}
className={itemIsActive ? activeLinkClasses : ""}
>
<Link prefetch href={buildHrefWithPeriod(item.url)}>
<item.icon className="size-4" />
{item.title}
</Link>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90 px-2 trasition-transform duration-200">
<RiArrowRightSLine className="text-primary" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => {
const subItemIsActive = isLinkActive(
subItem.url,
);
const avatarSrc = getAvatarSrc(
subItem.avatarUrl,
);
const initial =
subItem.title.charAt(0).toUpperCase() || "?";
return (
<SidebarMenuSubItem
key={subItem.key ?? subItem.title}
>
<SidebarMenuSubButton
asChild
isActive={subItemIsActive}
className={
subItemIsActive ? activeLinkClasses : ""
}
>
<Link
prefetch
href={buildHrefWithPeriod(subItem.url)}
className="flex items-center gap-2"
>
{subItem.icon ? (
<subItem.icon className="size-4" />
) : subItem.avatarUrl !== undefined ? (
<Avatar className="size-5 border border-border/60 bg-background">
<AvatarImage
src={avatarSrc}
alt={`Avatar de ${subItem.title}`}
/>
<AvatarFallback className="text-xs font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
) : null}
<span>{subItem.title}</span>
{subItem.badge ? (
<Badge
variant="destructive"
className="ml-auto h-5 min-w-5 px-1.5 text-xs"
>
{subItem.badge}
</Badge>
) : null}
{subItem.isShared ? (
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
) : null}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
);
}

View File

@@ -1,66 +0,0 @@
"use client";
import type { RemixiconComponentType } from "@remixicon/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type * as React from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: RemixiconComponentType;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname();
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const normalizedUrl =
item.url.endsWith("/") && item.url !== "/"
? item.url.slice(0, -1)
: item.url;
const itemIsActive =
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={itemIsActive}
className={
itemIsActive
? "data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!"
: ""
}
>
<Link prefetch href={item.url}>
<item.icon className={"h-4 w-4"} />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,54 +0,0 @@
"use client";
import Image from "next/image";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/components/ui/sidebar";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
type NavUserProps = {
user: {
id: string;
name: string;
email: string;
image: string | null;
};
pagadorAvatarUrl: string | null;
};
export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
const isDataUrl = avatarSrc.startsWith("data:");
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground "
>
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
<Image
src={avatarSrc}
unoptimized={isDataUrl}
alt={user.name}
fill
sizes="32px"
className="object-cover"
/>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,3 +0,0 @@
export { PrivacyProvider } from "./privacy-provider";
export { QueryProvider } from "./query-provider";
export { ThemeProvider } from "./theme-provider";

View File

@@ -1,726 +0,0 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { RiLayoutLeft2Line } from "@remixicon/react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Separator } from "@/shared/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet";
import { Skeleton } from "@/shared/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
import { useIsMobile } from "./use-mobile";
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 SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(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]);
// 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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
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 { state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
return (
<>
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 md:hidden [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
</>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<RiLayoutLeft2Line />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn(
"relative flex w-full min-w-0 flex-col px-2 py-1",
className,
)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -1 +0,0 @@
export { useIsMobile, useMobile } from "@/shared/hooks/use-mobile";

View File

@@ -1,3 +0,0 @@
export { useControlledState } from "./use-controlled-state";
export { useFormState } from "./use-form-state";
export { useIsMobile, useMobile } from "./use-mobile";

View File

@@ -1,28 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
export function useIsMobile() {
const subscribe = React.useCallback((onStoreChange: () => void) => {
if (typeof window === "undefined") {
return () => {};
}
const mediaQueryList = window.matchMedia(MOBILE_MEDIA_QUERY);
mediaQueryList.addEventListener("change", onStoreChange);
return () => mediaQueryList.removeEventListener("change", onStoreChange);
}, []);
const getSnapshot = React.useCallback(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
}, []);
return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}
export const useMobile = useIsMobile;

View File

@@ -1,3 +0,0 @@
export * from "./common";
export * from "./inbox";
export * from "./insights";

View File

@@ -1,4 +0,0 @@
export * from "./actions";
export * from "./calendar";
export * from "./notifications";
export * from "./reports";