mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
feat(admin): make the left navigation bar collapsable (#10774)
This commit is contained in:
@@ -34,7 +34,7 @@ export const CollapsibleItem = ({
|
||||
);
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full ">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<AccordionItem value="item-1" className="border-b-0 ml-7 ">
|
||||
<NavLink
|
||||
to={`/admin/settings/${title}`}
|
||||
className={({ isActive }) => {
|
||||
@@ -45,12 +45,12 @@ export const CollapsibleItem = ({
|
||||
>
|
||||
<AccordionTrigger
|
||||
onClick={() => handleClick(title)}
|
||||
className={`py-2 px-3 rounded`}
|
||||
className="py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360"
|
||||
>
|
||||
{title}
|
||||
</AccordionTrigger>
|
||||
</NavLink>
|
||||
<AccordionContent className=" flex flex-col gap-2 py-1">
|
||||
<AccordionContent className="flex flex-col gap-2 py-1">
|
||||
{items.map(item => (
|
||||
<NavLink
|
||||
key={item}
|
||||
@@ -74,3 +74,34 @@ export const CollapsibleItem = ({
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const OtherModules = ({
|
||||
moduleList,
|
||||
changeModule,
|
||||
}: {
|
||||
moduleList: {
|
||||
moduleName: string;
|
||||
keys: string[];
|
||||
}[];
|
||||
changeModule?: (module: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full ">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<AccordionTrigger className="ml-7 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
|
||||
Other
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-2 py-1">
|
||||
{moduleList.map(module => (
|
||||
<CollapsibleItem
|
||||
key={module.moduleName}
|
||||
items={module.keys}
|
||||
title={module.moduleName}
|
||||
changeModule={changeModule}
|
||||
/>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
62
packages/frontend/admin/src/modules/nav/nav-item.tsx
Normal file
62
packages/frontend/admin/src/modules/nav/nav-item.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
isActive?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export const NavItem = ({ icon, label, to, isCollapsed }: NavItemProps) => {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'w-10 h-10',
|
||||
size: 'icon',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||
},
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
}),
|
||||
'justify-start flex-none text-sm font-medium px-2'
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||
},
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
@@ -1,142 +1,123 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { ClipboardListIcon, SettingsIcon, UsersIcon } from 'lucide-react';
|
||||
import { AccountIcon, AiOutlineIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
|
||||
import { CollapsibleItem } from './collapsible-item';
|
||||
import { useNav } from './context';
|
||||
import { SettingsItem } from './settings-item';
|
||||
import { UserDropdown } from './user-dropdown';
|
||||
|
||||
export function Nav() {
|
||||
const { moduleList } = useGetServerRuntimeConfig();
|
||||
const { setCurrentModule } = useNav();
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
isActive?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const NavItem = ({ icon, label, to, isCollapsed }: NavItemProps) => {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'w-10 h-10',
|
||||
size: 'icon',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||
},
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-2 justify-between flex-grow overflow-hidden">
|
||||
<nav className="flex flex-col gap-1 px-2 flex-grow overflow-hidden">
|
||||
<NavLink
|
||||
to={'/admin/accounts'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
isActive &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
}),
|
||||
'justify-start flex-none text-sm font-medium px-2'
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
|
||||
},
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export function Nav({ isCollapsed = false }: NavProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 py-2 justify-between flex-grow h-full overflow-hidden',
|
||||
isCollapsed && 'overflow-visible'
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-hidden',
|
||||
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
||||
)}
|
||||
>
|
||||
<NavItem
|
||||
to="/admin/config"
|
||||
icon={
|
||||
<SelfhostIcon className={cn(!isCollapsed && 'mr-2', 'h-5 w-5')} />
|
||||
}
|
||||
>
|
||||
<UsersIcon className="mr-2 h-4 w-4" />
|
||||
Accounts
|
||||
</NavLink>
|
||||
{/* <Link
|
||||
to={'/admin/ai'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'AI' ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
activeTab === 'AI' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)}
|
||||
>
|
||||
<CpuIcon className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Link> */}
|
||||
<NavLink
|
||||
to={'/admin/config'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
isActive &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)
|
||||
label="Server"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/accounts"
|
||||
icon={
|
||||
<AccountIcon className={cn(!isCollapsed && 'mr-2', 'h-5 w-5')} />
|
||||
}
|
||||
>
|
||||
<ClipboardListIcon className="mr-2 h-4 w-4" />
|
||||
Config
|
||||
</NavLink>
|
||||
label="Accounts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/ai"
|
||||
icon={
|
||||
<AiOutlineIcon className={cn(!isCollapsed && 'mr-2', 'h-5 w-5')} />
|
||||
}
|
||||
label="AI"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
|
||||
<Accordion type="multiple" className="w-full h-full overflow-hidden">
|
||||
<AccordionItem
|
||||
value="item-1"
|
||||
className="border-b-0 h-full flex flex-col gap-1 w-full"
|
||||
>
|
||||
<NavLink
|
||||
to={'/admin/settings'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
isActive &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none',
|
||||
'w-full'
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className={'flex items-center justify-between w-full'}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</NavLink>
|
||||
|
||||
<AccordionContent className="h-full overflow-hidden w-full">
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden w-full h-full')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{moduleList.map(module => (
|
||||
<CollapsibleItem
|
||||
key={module.moduleName}
|
||||
items={module.keys}
|
||||
title={module.moduleName}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
))}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]'
|
||||
)}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<SettingsItem isCollapsed={isCollapsed} />
|
||||
</nav>
|
||||
|
||||
<UserDropdown />
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 px-2 flex-col overflow-hidden',
|
||||
isCollapsed && 'items-center px-0 gap-1'
|
||||
)}
|
||||
>
|
||||
<UserDropdown isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
162
packages/frontend/admin/src/modules/nav/settings-item.tsx
Normal file
162
packages/frontend/admin/src/modules/nav/settings-item.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useMemo } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
|
||||
import { CollapsibleItem, OtherModules } from './collapsible-item';
|
||||
import { useNav } from './context';
|
||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
const { moduleList } = useGetServerRuntimeConfig();
|
||||
const { setCurrentModule } = useNav();
|
||||
|
||||
const { authModule, otherModules } = useMemo(() => {
|
||||
const authModule = moduleList.find(module => module.moduleName === 'auth');
|
||||
const otherModules = moduleList.filter(
|
||||
module => module.moduleName !== 'auth'
|
||||
);
|
||||
return { authModule, otherModules };
|
||||
}, [moduleList]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
className="flex-none relative"
|
||||
orientation="vertical"
|
||||
>
|
||||
<NavigationMenuPrimitive.List>
|
||||
<NavigationMenuPrimitive.Item>
|
||||
<NavigationMenuPrimitive.Trigger className="[&>svg]:hidden m-0 p-0">
|
||||
<NavLink
|
||||
to={'/admin/settings'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'w-10 h-10',
|
||||
size: 'icon',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
<NavigationMenuPrimitive.Content>
|
||||
<ul
|
||||
className="border rounded-lg w-full flex flex-col p-1"
|
||||
style={{
|
||||
backgroundColor: cssVarV2('layer/background/overlayPanel'),
|
||||
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
||||
}}
|
||||
>
|
||||
{moduleList.map(module => (
|
||||
<li key={module.moduleName} className="flex">
|
||||
<NavLink
|
||||
to={`/admin/settings/${module.moduleName}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
className:
|
||||
'p-1.5 rounded-[6px] text-[14px] w-full justify-start',
|
||||
})
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
onClick={() => setCurrentModule?.(module.moduleName)}
|
||||
>
|
||||
{module.moduleName}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuPrimitive.Content>
|
||||
</NavigationMenuPrimitive.Item>
|
||||
</NavigationMenuPrimitive.List>
|
||||
<NavigationMenuPrimitive.Viewport className="absolute z-10 left-11 top-0" />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full h-full overflow-hidden">
|
||||
<AccordionItem
|
||||
value="item-1"
|
||||
className="border-b-0 h-full flex flex-col gap-1 w-full"
|
||||
>
|
||||
<NavLink
|
||||
to={'/admin/settings'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
}),
|
||||
'justify-start flex-none w-full px-2'
|
||||
)}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className={
|
||||
'flex items-center justify-between w-full [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<SettingsIcon className={cn(!isCollapsed && 'mr-2', 'h-5 w-5')} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</NavLink>
|
||||
|
||||
<AccordionContent className="h-full overflow-hidden w-full">
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden w-full h-full')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{authModule && (
|
||||
<CollapsibleItem
|
||||
items={authModule.keys}
|
||||
title={authModule.moduleName}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
)}
|
||||
{otherModules.length > 0 && (
|
||||
<OtherModules
|
||||
moduleList={otherModules}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]'
|
||||
)}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,11 @@ import { toast } from 'sonner';
|
||||
|
||||
import { useCurrentUser, useRevalidateCurrentUser } from '../common';
|
||||
|
||||
export function UserDropdown() {
|
||||
interface UserDropdownProps {
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
||||
const currentUser = useCurrentUser();
|
||||
const relative = useRevalidateCurrentUser();
|
||||
|
||||
@@ -33,10 +37,34 @@ export function UserDropdown() {
|
||||
});
|
||||
}, [relative]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-10 h-10" size="icon">
|
||||
<Avatar className="w-5 h-5">
|
||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<CircleUser size={24} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-none items-center justify-between px-4 py-3 flex-nowrap">
|
||||
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
|
||||
<Avatar className="w-6 h-6">
|
||||
<div
|
||||
className={`flex flex-none items-center ${isCollapsed ? 'justify-center' : 'justify-between'} px-1 py-3 flex-nowrap`}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
|
||||
<Avatar className="w-5 h-5">
|
||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<CircleUser size={24} />
|
||||
@@ -66,7 +94,7 @@ export function UserDropdown() {
|
||||
<MoreVertical size={20} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
|
||||
|
||||
Reference in New Issue
Block a user