feat(admin): make the left navigation bar collapsable (#10774)

This commit is contained in:
JimmFly
2025-03-13 09:57:09 +00:00
parent a4608b52f2
commit 21aa47c094
22 changed files with 782 additions and 361 deletions

View File

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

View 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>
);
};

View File

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

View 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>
);
};

View File

@@ -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>