diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index f49d21e8b..ca4c6bf58 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -122,7 +122,6 @@ "editDetailsDescription": "Change the details of your event.", "finalizeDescription": "Select a final date for your event.", "notificationsGuestTooltip": "Create an account or login to turn on notifications", - "planFree": "Free", "dateAndTimeDescription": "Change your preferred date and time settings", "createdTime": "Created {relativeTime}", "permissionDeniedParticipant": "If you are not the poll creator, you should go to the Invite Page.", @@ -396,5 +395,10 @@ "actionErrorNotFound": "The resource was not found", "actionErrorForbidden": "You are not allowed to perform this action", "actionErrorInternalServerError": "An internal server error occurred", - "cancelEvent": "Cancel Event" + "cancelEvent": "Cancel Event", + "createSpace": "Create Space", + "createSpaceDescription": "Create a new space to organize your polls and events.", + "createSpaceLoading": "Creating space...", + "createSpaceSuccess": "Space created successfully", + "createSpaceError": "Failed to create space" } diff --git a/apps/web/src/app/[locale]/(space)/components/sidebar/nav-item.tsx b/apps/web/src/app/[locale]/(space)/components/sidebar/nav-item.tsx deleted file mode 100644 index 5212f11bd..000000000 --- a/apps/web/src/app/[locale]/(space)/components/sidebar/nav-item.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { SidebarMenuButton, SidebarMenuItem } from "@rallly/ui/sidebar"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -export function NavItem({ - href, - children, -}: { - href: string; - children: React.ReactNode; -}) { - const pathname = usePathname(); - const isActive = pathname === href; - - return ( - - - {children} - - - ); -} diff --git a/apps/web/src/app/[locale]/(space)/components/sidebar/nav-user.tsx b/apps/web/src/app/[locale]/(space)/components/sidebar/nav-user.tsx index a4ed6040e..3aa056609 100644 --- a/apps/web/src/app/[locale]/(space)/components/sidebar/nav-user.tsx +++ b/apps/web/src/app/[locale]/(space)/components/sidebar/nav-user.tsx @@ -24,7 +24,7 @@ export function NavUser({
{name}
-
+
{plan}
diff --git a/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-menu.tsx b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-menu.tsx new file mode 100644 index 000000000..ab99d1374 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-menu.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useTranslation } from "@/i18n/client"; +import { Icon } from "@rallly/ui/icon"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@rallly/ui/sidebar"; +import { BarChart2Icon, CalendarIcon, HomeIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const useSpaceMenuItems = () => { + const { t } = useTranslation(); + return [ + { + href: "/", + icon: , + label: t("home", { defaultValue: "Home" }), + }, + { + href: "/polls", + icon: , + label: t("polls", { defaultValue: "Polls" }), + }, + { + href: "/events", + icon: , + label: t("events", { defaultValue: "Events" }), + }, + ]; +}; + +export function SpaceSidebarMenu() { + const items = useSpaceMenuItems(); + const pathname = usePathname(); + return ( + + {items.map((item) => ( + + + + {item.icon} + {item.label} + + + + ))} + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar-provider.tsx b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-provider.tsx similarity index 92% rename from apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar-provider.tsx rename to apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-provider.tsx index f7c9e0f6f..da60a034a 100644 --- a/apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar-provider.tsx +++ b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar-provider.tsx @@ -3,7 +3,7 @@ import { SidebarProvider } from "@rallly/ui/sidebar"; import { useLocalStorage } from "react-use"; -export function AppSidebarProvider({ +export function SpaceSidebarProvider({ children, }: { children: React.ReactNode; diff --git a/apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar.tsx b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar.tsx similarity index 62% rename from apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar.tsx rename to apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar.tsx index d4c2d21e6..eb805af3e 100644 --- a/apps/web/src/app/[locale]/(space)/components/sidebar/app-sidebar.tsx +++ b/apps/web/src/app/[locale]/(space)/components/sidebar/space-sidebar.tsx @@ -1,3 +1,12 @@ +import { SpaceSidebarMenu } from "@/app/[locale]/(space)/components/sidebar/space-sidebar-menu"; +import { LogoLink } from "@/app/components/logo-link"; +import { getActiveSpace, requireUserAbility } from "@/auth/queries"; +import { Trans } from "@/components/trans"; +import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle"; +import { SpaceDropdown } from "@/features/spaces/components/space-dropdown"; +import { SpaceIcon } from "@/features/spaces/components/space-icon"; +import { isSpacesEnabled } from "@/features/spaces/constants"; +import { loadSpaces } from "@/features/spaces/queries"; import { Button } from "@rallly/ui/button"; import { Icon } from "@rallly/ui/icon"; import { @@ -6,33 +15,34 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, - SidebarMenu, SidebarSeparator, } from "@rallly/ui/sidebar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; -import { - BarChart2Icon, - CalendarIcon, - HomeIcon, - PlusIcon, - SparklesIcon, -} from "lucide-react"; +import { ChevronsUpDownIcon, PlusIcon, SparklesIcon } from "lucide-react"; import Link from "next/link"; -import type * as React from "react"; - -import { LogoLink } from "@/app/components/logo-link"; -import { Trans } from "@/components/trans"; -import { FeedbackToggle } from "@/features/feedback/components/feedback-toggle"; - -import { requireUser } from "@/auth/queries"; +import type React from "react"; import { UpgradeButton } from "../upgrade-button"; -import { NavItem } from "./nav-item"; import { NavUser } from "./nav-user"; -export async function AppSidebar({ +async function loadData() { + const [{ user }, spaces, activeSpace] = await Promise.all([ + requireUserAbility(), + loadSpaces(), + getActiveSpace(), + ]); + + return { + user, + spaces, + activeSpace, + }; +} + +export async function SpaceSidebar({ ...props }: React.ComponentProps) { - const user = await requireUser(); + const { user, spaces, activeSpace } = await loadData(); + return ( @@ -58,23 +68,28 @@ export async function AppSidebar({
+ {isSpacesEnabled ? ( +
+ + + +
+ ) : null} - - - - - - - - - - - - - - + @@ -106,11 +121,7 @@ export async function AppSidebar({ name={user.name} image={user.image} plan={ - user.isPro ? ( - - ) : ( - - ) + {activeSpace.role.toLowerCase()} } /> diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx index 4e7796d43..284205fa5 100644 --- a/apps/web/src/app/[locale]/(space)/layout.tsx +++ b/apps/web/src/app/[locale]/(space)/layout.tsx @@ -8,22 +8,30 @@ import { getOnboardedUser } from "@/features/setup/queries"; import { TimezoneProvider } from "@/features/timezone/client/context"; import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning"; -import { AppSidebar } from "./components/sidebar/app-sidebar"; -import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider"; +import { SpaceSidebar } from "./components/sidebar/space-sidebar"; +import { SpaceSidebarProvider } from "./components/sidebar/space-sidebar-provider"; import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar"; +async function loadData() { + const [user] = await Promise.all([getOnboardedUser()]); + + return { + user, + }; +} + export default async function Layout({ children, }: { children: React.ReactNode; }) { - const user = await getOnboardedUser(); + const { user } = await loadData(); return ( - + - + @@ -51,7 +59,7 @@ export default async function Layout({
{children}
-
+
); } diff --git a/apps/web/src/features/spaces/actions.ts b/apps/web/src/features/spaces/actions.ts new file mode 100644 index 000000000..40e09936e --- /dev/null +++ b/apps/web/src/features/spaces/actions.ts @@ -0,0 +1,138 @@ +"use server"; + +import { requireUserAbility } from "@/auth/queries"; +import { ActionError, authActionClient } from "@/features/safe-action/server"; +import { subject } from "@casl/ability"; +import { accessibleBy } from "@casl/prisma"; +import { prisma } from "@rallly/database"; +import { z } from "zod"; + +export const setActiveSpaceAction = authActionClient + .inputSchema( + z.object({ + spaceId: z.string(), + }), + ) + .metadata({ + actionName: "space_set_active", + }) + .action(async ({ parsedInput }) => { + const { user, ability } = await requireUserAbility(); + + const space = await prisma.space.findFirst({ + where: { + AND: [accessibleBy(ability).Space, { id: parsedInput.spaceId }], + }, + include: { + members: { + where: { + userId: user.id, + }, + }, + }, + }); + + if (!space) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Space not found", + }); + } + + if (!ability.can("read", subject("Space", space))) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You do not have access to this space", + }); + } + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + activeSpaceId: parsedInput.spaceId, + }, + }); + }); + +export const createSpaceAction = authActionClient + .metadata({ + actionName: "space_create", + }) + .inputSchema( + z.object({ + name: z.string().min(1).max(100), + }), + ) + .action(async ({ parsedInput }) => { + const { user } = await requireUserAbility(); + + const space = await prisma.space.create({ + data: { + name: parsedInput.name, + ownerId: user.id, + members: { + create: { + userId: user.id, + role: "OWNER", + }, + }, + }, + }); + + try { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + activeSpaceId: space.id, + }, + }); + } catch (error) { + console.error(error); + } + }); + +export const deleteSpaceAction = authActionClient + .metadata({ + actionName: "space_delete", + }) + .inputSchema( + z.object({ + spaceId: z.string(), + }), + ) + .action(async ({ parsedInput }) => { + const { ability } = await requireUserAbility(); + + const space = await prisma.space.findFirst({ + where: { + AND: [accessibleBy(ability).Space, { id: parsedInput.spaceId }], + }, + include: { + subscription: true, + }, + }); + + if (!space) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Space not found", + }); + } + + if (ability.cannot("delete", subject("Space", space))) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You do not have access to this space", + }); + } + + await prisma.space.delete({ + where: { + id: parsedInput.spaceId, + }, + }); + }); diff --git a/apps/web/src/features/spaces/client.tsx b/apps/web/src/features/spaces/client.tsx new file mode 100644 index 000000000..7d12697e4 --- /dev/null +++ b/apps/web/src/features/spaces/client.tsx @@ -0,0 +1,24 @@ +"use client"; +import type { SpaceDTO } from "@/features/spaces/queries"; +import React from "react"; + +export const SpaceContext = React.createContext(null); + +export const useSpace = () => { + const space = React.useContext(SpaceContext); + + if (!space) { + throw new Error("useSpace must be used within a SpaceProvider"); + } + + return space; +}; + +export const SpaceProvider = ({ + space, + children, +}: { space: SpaceDTO; children?: React.ReactNode }) => { + return ( + {children} + ); +}; diff --git a/apps/web/src/features/spaces/components/new-space-dialog.tsx b/apps/web/src/features/spaces/components/new-space-dialog.tsx new file mode 100644 index 000000000..7629b69a1 --- /dev/null +++ b/apps/web/src/features/spaces/components/new-space-dialog.tsx @@ -0,0 +1,122 @@ +"use client"; +import { Trans } from "@/components/trans"; +import { useSafeAction } from "@/features/safe-action/client"; +import { createSpaceAction } from "@/features/spaces/actions"; +import { useTranslation } from "@/i18n/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@rallly/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + type DialogProps, + DialogTitle, +} from "@rallly/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@rallly/ui/form"; +import { Input } from "@rallly/ui/input"; +import { toast } from "@rallly/ui/sonner"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const newSpaceFormSchema = z.object({ + name: z.string().min(1).max(100), +}); + +type NewSpaceFormValues = z.infer; + +export function NewSpaceDialog({ children, ...props }: DialogProps) { + const { t } = useTranslation(); + + const form = useForm({ + resolver: zodResolver(newSpaceFormSchema), + defaultValues: { + name: "", + }, + }); + + const createSpace = useSafeAction(createSpaceAction, { + onSuccess: () => { + form.reset(); + }, + }); + + return ( + + {children} +
+ + + + + + + + + + { + props.onOpenChange?.(false); + toast.promise( + createSpace.executeAsync({ + name, + }), + { + loading: t("createSpaceLoading", { + defaultValue: "Creating space...", + }), + success: t("createSpaceSuccess", { + defaultValue: "Space created successfully", + }), + error: t("createSpaceError", { + defaultValue: "Failed to create space", + }), + }, + ); + })} + > + ( + + + + + + + + + + )} + /> + + + + +
+ +
+ ); +} diff --git a/apps/web/src/features/spaces/components/space-dropdown.tsx b/apps/web/src/features/spaces/components/space-dropdown.tsx new file mode 100644 index 000000000..aa27c0c99 --- /dev/null +++ b/apps/web/src/features/spaces/components/space-dropdown.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { ProBadge } from "@/components/pro-badge"; +import { Trans } from "@/components/trans"; +import { useSafeAction } from "@/features/safe-action/client"; +import { setActiveSpaceAction } from "@/features/spaces/actions"; +import { cn } from "@rallly/ui"; +import { useDialog } from "@rallly/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@rallly/ui/dropdown-menu"; +import { Icon } from "@rallly/ui/icon"; +import { CirclePlusIcon, SettingsIcon } from "lucide-react"; +import Link from "next/link"; +import { NewSpaceDialog } from "./new-space-dialog"; +import { SpaceIcon } from "./space-icon"; + +export function SpaceDropdown({ + spaces, + activeSpaceId, + children, +}: { + spaces: { + id: string; + name: string; + isPro: boolean; + }[]; + activeSpaceId: string; + children?: React.ReactNode; +}) { + const setActiveSpace = useSafeAction(setActiveSpaceAction); + const newSpaceDialog = useDialog(); + const activeSpace = spaces.find((space) => space.id === activeSpaceId); + if (!activeSpace) { + return null; + } + + return ( + <> + + + {children} + + + { + setActiveSpace.execute({ spaceId: value }); + }} + > + {spaces.map((space) => ( + + + {space.name} + {space.isPro ? : null} + + ))} + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/features/spaces/components/space-icon.tsx b/apps/web/src/features/spaces/components/space-icon.tsx new file mode 100644 index 000000000..44c5cadd3 --- /dev/null +++ b/apps/web/src/features/spaces/components/space-icon.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { cn } from "@rallly/ui"; +import { Avatar, AvatarFallback, type AvatarProps } from "@rallly/ui/avatar"; + +type SpaceIconProps = { + name: string; + className?: string; +} & AvatarProps; + +export function SpaceIcon({ name, className, size }: SpaceIconProps) { + return ( + + {name[0]} + + ); +} diff --git a/apps/web/src/features/spaces/constants.ts b/apps/web/src/features/spaces/constants.ts new file mode 100644 index 000000000..92a14de66 --- /dev/null +++ b/apps/web/src/features/spaces/constants.ts @@ -0,0 +1 @@ +export const isSpacesEnabled = false; diff --git a/apps/web/src/features/spaces/queries.ts b/apps/web/src/features/spaces/queries.ts index 0d34e3e28..33d33cfa4 100644 --- a/apps/web/src/features/spaces/queries.ts +++ b/apps/web/src/features/spaces/queries.ts @@ -1,19 +1,49 @@ import { requireUserAbility } from "@/auth/queries"; import { accessibleBy } from "@casl/prisma"; -import { prisma } from "@rallly/database"; +import { type SpaceMemberRole, prisma } from "@rallly/database"; +import { redirect } from "next/navigation"; import { cache } from "react"; -export const listSpaces = cache(async () => { - const { ability } = await requireUserAbility(); - const spaces = await prisma.spaceMember.findMany({ - where: accessibleBy(ability).SpaceMember, - include: { space: true }, +export type SpaceDTO = { + id: string; + name: string; + ownerId: string; + isPro: boolean; + role: SpaceMemberRole; +}; + +export const loadSpaces = cache(async () => { + const { user, ability } = await requireUserAbility(); + const spaces = await prisma.space.findMany({ + where: accessibleBy(ability).Space, + include: { + subscription: true, + members: true, + }, }); - return spaces.map((spaceMember) => ({ - ...spaceMember.space, - role: spaceMember.role, - })); + const availableSpaces: SpaceDTO[] = []; + + for (const space of spaces) { + const role = space.members.find( + (member) => member.userId === user.id, + )?.role; + + if (!role) { + console.warn(`User ${user.id} does not have access to space ${space.id}`); + continue; + } + + availableSpaces.push({ + id: space.id, + name: space.name, + ownerId: space.ownerId, + isPro: Boolean(space.subscription?.active), + role, + }); + } + + return availableSpaces; }); export const getDefaultSpace = cache(async () => { @@ -25,19 +55,59 @@ export const getDefaultSpace = cache(async () => { orderBy: { createdAt: "asc", }, + include: { + subscription: true, + }, }); if (!space) { - throw new Error(`Space with owner ID ${user.id} not found`); + redirect("/setup"); } - return space; + return { + id: space.id, + name: space.name, + ownerId: space.ownerId, + isPro: Boolean(space.subscription?.active), + role: "OWNER", + } satisfies SpaceDTO; }); export const getSpace = cache(async ({ id }: { id: string }) => { - return await prisma.space.findFirst({ + const { user, ability } = await requireUserAbility(); + const space = await prisma.space.findFirst({ where: { - id, + AND: [accessibleBy(ability).Space, { id }], + }, + include: { + subscription: { + where: { + active: true, + }, + }, + members: { + where: { + userId: user.id, + }, + }, }, }); + + if (!space) { + throw new Error(`User ${user.id} does not have access to space ${id}`); + } + + const role = space.members.find((member) => member.userId === user.id)?.role; + + if (!role) { + throw new Error(`User ${user.id} is not a member of space ${id}`); + } + + return { + id: space.id, + name: space.name, + ownerId: space.ownerId, + isPro: Boolean(space.subscription?.active), + role, + } satisfies SpaceDTO; }); diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx index 07273fc96..f066d67e8 100644 --- a/packages/ui/src/avatar.tsx +++ b/packages/ui/src/avatar.tsx @@ -8,10 +8,10 @@ import * as React from "react"; const avatarVariants = cva("relative flex shrink-0 overflow-hidden", { variants: { size: { - xl: "size-12 text-xl", - lg: "size-9 text-lg", - md: "size-7 text-base", - sm: "size-5 text-xs", + xl: "size-12 rounded-md text-xl", + lg: "size-12 rounded-md text-lg", + md: "size-9 rounded-md text-base", + sm: "size-5 rounded-md text-xs", }, }, defaultVariants: { @@ -19,6 +19,8 @@ const avatarVariants = cva("relative flex shrink-0 overflow-hidden", { }, }); +export type AvatarProps = VariantProps; + const Avatar = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef & diff --git a/packages/ui/src/badge.tsx b/packages/ui/src/badge.tsx index 329b7ef55..9a8fd1dac 100644 --- a/packages/ui/src/badge.tsx +++ b/packages/ui/src/badge.tsx @@ -13,7 +13,7 @@ const badgeVariants = cva( destructive: "bg-destructive text-destructive-foreground", outline: "text-foreground", green: "bg-green-600 text-white", - secondary: "border border-primary-200 bg-primary-50 text-primary", + secondary: "bg-primary-50 text-primary", }, size: { sm: "h-5 min-w-5 px-1.5 text-xs", diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index 1fd8da973..cad4ba092 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -31,6 +31,7 @@ const buttonVariants = cva( size: { default: "h-8 gap-x-2 rounded-md px-2 text-sm", sm: "h-7 gap-x-1.5 rounded-md px-1.5 text-sm", + md: "h-9 gap-x-2 rounded-md px-2 text-sm", lg: "h-12 gap-x-3 rounded-lg px-4 text-base", icon: "size-7 gap-x-1.5 rounded-lg text-sm", "icon-lg": "size-8 rounded-full", diff --git a/packages/ui/src/sidebar.tsx b/packages/ui/src/sidebar.tsx index 2f8ba2554..e507990ab 100644 --- a/packages/ui/src/sidebar.tsx +++ b/packages/ui/src/sidebar.tsx @@ -526,7 +526,7 @@ const SidebarMenuItem = React.forwardRef< SidebarMenuItem.displayName = "SidebarMenuItem"; const sidebarMenuButtonVariants = cva( - "peer/menu-button group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 flex w-full items-center gap-2.5 overflow-hidden rounded-lg p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] focus-visible:ring-2 focus-visible:ring-gray-400 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]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "peer/menu-button group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 flex w-full items-center gap-2.5 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] focus-visible:ring-2 focus-visible:ring-gray-400 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]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: {