🚧 Add space components (#1819)

This commit is contained in:
Luke Vella 2025-07-15 10:36:45 +01:00 committed by GitHub
parent d93615befe
commit 56c74d5024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 615 additions and 91 deletions

View file

@ -122,7 +122,6 @@
"editDetailsDescription": "Change the details of your event.", "editDetailsDescription": "Change the details of your event.",
"finalizeDescription": "Select a final date for your event.", "finalizeDescription": "Select a final date for your event.",
"notificationsGuestTooltip": "Create an account or login to turn on notifications", "notificationsGuestTooltip": "Create an account or login to turn on notifications",
"planFree": "Free",
"dateAndTimeDescription": "Change your preferred date and time settings", "dateAndTimeDescription": "Change your preferred date and time settings",
"createdTime": "Created {relativeTime}", "createdTime": "Created {relativeTime}",
"permissionDeniedParticipant": "If you are not the poll creator, you should go to the Invite Page.", "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", "actionErrorNotFound": "The resource was not found",
"actionErrorForbidden": "You are not allowed to perform this action", "actionErrorForbidden": "You are not allowed to perform this action",
"actionErrorInternalServerError": "An internal server error occurred", "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"
} }

View file

@ -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 (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={href}>{children}</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View file

@ -24,7 +24,7 @@ export function NavUser({
<OptimizedAvatarImage size="md" src={image} name={name} /> <OptimizedAvatarImage size="md" src={image} name={name} />
<div className="flex-1 truncate text-left"> <div className="flex-1 truncate text-left">
<div className="font-medium">{name}</div> <div className="font-medium">{name}</div>
<div className="mt-0.5 truncate font-normal text-muted-foreground"> <div className="mt-0.5 truncate font-normal text-muted-foreground text-xs">
{plan} {plan}
</div> </div>
</div> </div>

View file

@ -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: <HomeIcon />,
label: t("home", { defaultValue: "Home" }),
},
{
href: "/polls",
icon: <BarChart2Icon />,
label: t("polls", { defaultValue: "Polls" }),
},
{
href: "/events",
icon: <CalendarIcon />,
label: t("events", { defaultValue: "Events" }),
},
];
};
export function SpaceSidebarMenu() {
const items = useSpaceMenuItems();
const pathname = usePathname();
return (
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={pathname === item.href}>
<Link href={item.href}>
<Icon>{item.icon}</Icon>
{item.label}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
);
}

View file

@ -3,7 +3,7 @@
import { SidebarProvider } from "@rallly/ui/sidebar"; import { SidebarProvider } from "@rallly/ui/sidebar";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
export function AppSidebarProvider({ export function SpaceSidebarProvider({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;

View file

@ -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 { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
import { import {
@ -6,33 +15,34 @@ import {
SidebarFooter, SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarHeader, SidebarHeader,
SidebarMenu,
SidebarSeparator, SidebarSeparator,
} from "@rallly/ui/sidebar"; } from "@rallly/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { import { ChevronsUpDownIcon, PlusIcon, SparklesIcon } from "lucide-react";
BarChart2Icon,
CalendarIcon,
HomeIcon,
PlusIcon,
SparklesIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type * as React from "react"; import type 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 { UpgradeButton } from "../upgrade-button"; import { UpgradeButton } from "../upgrade-button";
import { NavItem } from "./nav-item";
import { NavUser } from "./nav-user"; 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 ...props
}: React.ComponentProps<typeof Sidebar>) { }: React.ComponentProps<typeof Sidebar>) {
const user = await requireUser(); const { user, spaces, activeSpace } = await loadData();
return ( return (
<Sidebar variant="inset" {...props}> <Sidebar variant="inset" {...props}>
<SidebarHeader> <SidebarHeader>
@ -58,23 +68,28 @@ export async function AppSidebar({
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{isSpacesEnabled ? (
<div>
<SpaceDropdown spaces={spaces} activeSpaceId={activeSpace.id}>
<Button className="h-auto w-full rounded-lg p-1" variant="ghost">
<SpaceIcon name={activeSpace.name} />
<div className="flex-1 px-0.5 text-left">
<div>{activeSpace.name}</div>
<div className="text-muted-foreground text-xs">
{activeSpace.isPro ? "Pro" : "Free"}
</div>
</div>
<Icon>
<ChevronsUpDownIcon />
</Icon>
</Button>
</SpaceDropdown>
</div>
) : null}
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SpaceSidebarMenu />
<NavItem href="/">
<HomeIcon className="size-4" />
<Trans i18nKey="home" />
</NavItem>
<NavItem href="/polls">
<BarChart2Icon className="size-4" />
<Trans i18nKey="polls" />
</NavItem>
<NavItem href="/events">
<CalendarIcon className="size-4" />
<Trans i18nKey="events" />
</NavItem>
</SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
@ -106,11 +121,7 @@ export async function AppSidebar({
name={user.name} name={user.name}
image={user.image} image={user.image}
plan={ plan={
user.isPro ? ( <span className="capitalize">{activeSpace.role.toLowerCase()}</span>
<Trans i18nKey="planPro" defaults="Pro" />
) : (
<Trans i18nKey="planFree" defaults="Free" />
)
} }
/> />
</SidebarFooter> </SidebarFooter>

View file

@ -8,22 +8,30 @@ import { getOnboardedUser } from "@/features/setup/queries";
import { TimezoneProvider } from "@/features/timezone/client/context"; import { TimezoneProvider } from "@/features/timezone/client/context";
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning"; import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
import { AppSidebar } from "./components/sidebar/app-sidebar"; import { SpaceSidebar } from "./components/sidebar/space-sidebar";
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider"; import { SpaceSidebarProvider } from "./components/sidebar/space-sidebar-provider";
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar"; import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
async function loadData() {
const [user] = await Promise.all([getOnboardedUser()]);
return {
user,
};
}
export default async function Layout({ export default async function Layout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const user = await getOnboardedUser(); const { user } = await loadData();
return ( return (
<TimezoneProvider initialTimezone={user.timeZone}> <TimezoneProvider initialTimezone={user.timeZone}>
<AppSidebarProvider> <SpaceSidebarProvider>
<CommandMenu /> <CommandMenu />
<AppSidebar /> <SpaceSidebar />
<SidebarInset className="min-w-0"> <SidebarInset className="min-w-0">
<TopBar className="md:hidden"> <TopBar className="md:hidden">
<TopBarLeft> <TopBarLeft>
@ -51,7 +59,7 @@ export default async function Layout({
<div className="flex flex-1 flex-col">{children}</div> <div className="flex flex-1 flex-col">{children}</div>
</div> </div>
</SidebarInset> </SidebarInset>
</AppSidebarProvider> </SpaceSidebarProvider>
</TimezoneProvider> </TimezoneProvider>
); );
} }

View file

@ -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,
},
});
});

View file

@ -0,0 +1,24 @@
"use client";
import type { SpaceDTO } from "@/features/spaces/queries";
import React from "react";
export const SpaceContext = React.createContext<SpaceDTO | null>(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 (
<SpaceContext.Provider value={space}>{children}</SpaceContext.Provider>
);
};

View file

@ -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<typeof newSpaceFormSchema>;
export function NewSpaceDialog({ children, ...props }: DialogProps) {
const { t } = useTranslation();
const form = useForm<NewSpaceFormValues>({
resolver: zodResolver(newSpaceFormSchema),
defaultValues: {
name: "",
},
});
const createSpace = useSafeAction(createSpaceAction, {
onSuccess: () => {
form.reset();
},
});
return (
<Dialog {...props}>
{children}
<Form {...form}>
<DialogContent size="xs">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="createSpace" defaults="Create Space" />
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="createSpaceDescription"
defaults="Create a new space to organize your polls and events."
/>
</DialogDescription>
</DialogHeader>
<form
onSubmit={form.handleSubmit(({ name }) => {
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",
}),
},
);
})}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="name" defaults="Name" />
</FormLabel>
<FormControl>
<Input
data-1p-ignore="true"
placeholder="e.g. Acme Corp"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button
loading={form.formState.isSubmitting}
type="submit"
variant="primary"
>
<Trans i18nKey="createSpace" defaults="Create Space" />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Form>
</Dialog>
);
}

View file

@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger
className={cn({
"animate-pulse duration-500": setActiveSpace.isExecuting,
})}
asChild={Boolean(children)}
>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)]"
align="start"
>
<DropdownMenuRadioGroup
value={activeSpaceId}
onValueChange={(value) => {
setActiveSpace.execute({ spaceId: value });
}}
>
{spaces.map((space) => (
<DropdownMenuRadioItem
key={space.id}
value={space.id}
className="flex items-center gap-2"
>
<SpaceIcon size="sm" name={space.name} />
<span>{space.name}</span>
{space.isPro ? <ProBadge /> : null}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={newSpaceDialog.trigger}>
<Icon>
<CirclePlusIcon />
</Icon>
<Trans i18nKey="createSpace" defaults="Create Space" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings">
<Icon>
<SettingsIcon />
</Icon>
<Trans i18nKey="settings" defaults="Settings" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewSpaceDialog {...newSpaceDialog.dialogProps} />
</>
);
}

View file

@ -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 (
<Avatar className={cn(className)} size={size}>
<AvatarFallback seed={name}>{name[0]}</AvatarFallback>
</Avatar>
);
}

View file

@ -0,0 +1 @@
export const isSpacesEnabled = false;

View file

@ -1,19 +1,49 @@
import { requireUserAbility } from "@/auth/queries"; import { requireUserAbility } from "@/auth/queries";
import { accessibleBy } from "@casl/prisma"; 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"; import { cache } from "react";
export const listSpaces = cache(async () => { export type SpaceDTO = {
const { ability } = await requireUserAbility(); id: string;
const spaces = await prisma.spaceMember.findMany({ name: string;
where: accessibleBy(ability).SpaceMember, ownerId: string;
include: { space: true }, 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) => ({ const availableSpaces: SpaceDTO[] = [];
...spaceMember.space,
role: spaceMember.role, 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 () => { export const getDefaultSpace = cache(async () => {
@ -25,19 +55,59 @@ export const getDefaultSpace = cache(async () => {
orderBy: { orderBy: {
createdAt: "asc", createdAt: "asc",
}, },
include: {
subscription: true,
},
}); });
if (!space) { 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 }) => { 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: { 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;
}); });

View file

@ -8,10 +8,10 @@ import * as React from "react";
const avatarVariants = cva("relative flex shrink-0 overflow-hidden", { const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
variants: { variants: {
size: { size: {
xl: "size-12 text-xl", xl: "size-12 rounded-md text-xl",
lg: "size-9 text-lg", lg: "size-12 rounded-md text-lg",
md: "size-7 text-base", md: "size-9 rounded-md text-base",
sm: "size-5 text-xs", sm: "size-5 rounded-md text-xs",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -19,6 +19,8 @@ const avatarVariants = cva("relative flex shrink-0 overflow-hidden", {
}, },
}); });
export type AvatarProps = VariantProps<typeof avatarVariants>;
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>, React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> &

View file

@ -13,7 +13,7 @@ const badgeVariants = cva(
destructive: "bg-destructive text-destructive-foreground", destructive: "bg-destructive text-destructive-foreground",
outline: "text-foreground", outline: "text-foreground",
green: "bg-green-600 text-white", green: "bg-green-600 text-white",
secondary: "border border-primary-200 bg-primary-50 text-primary", secondary: "bg-primary-50 text-primary",
}, },
size: { size: {
sm: "h-5 min-w-5 px-1.5 text-xs", sm: "h-5 min-w-5 px-1.5 text-xs",

View file

@ -31,6 +31,7 @@ const buttonVariants = cva(
size: { size: {
default: "h-8 gap-x-2 rounded-md px-2 text-sm", 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", 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", lg: "h-12 gap-x-3 rounded-lg px-4 text-base",
icon: "size-7 gap-x-1.5 rounded-lg text-sm", icon: "size-7 gap-x-1.5 rounded-lg text-sm",
"icon-lg": "size-8 rounded-full", "icon-lg": "size-8 rounded-full",

View file

@ -526,7 +526,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem"; SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva( 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: { variants: {
variant: { variant: {