feat: improve admin panel design (#14464)

This commit is contained in:
DarkSky
2026-02-17 17:40:29 +08:00
committed by GitHub
parent 850e646ab9
commit 8f833388eb
86 changed files with 2633 additions and 1431 deletions
@@ -1,44 +0,0 @@
import { useCallback } from 'react';
import { NavLink } from 'react-router-dom';
import { buttonVariants } from '../../components/ui/button';
import { cn } from '../../utils';
export const NormalSubItem = ({
module,
title,
changeModule,
indent = 'normal',
}: {
module: string;
title: string;
changeModule?: (module: string) => void;
indent?: 'normal' | 'nested';
}) => {
const handleClick = useCallback(() => {
changeModule?.(module);
}, [changeModule, module]);
const indentClassName = indent === 'nested' ? 'ml-12' : 'ml-8';
return (
<div className="w-full flex">
<NavLink
to={`/admin/settings/${module}`}
onClick={handleClick}
className={({ isActive }) => {
return cn(
buttonVariants({
variant: 'ghost',
className: cn(
indentClassName,
'px-2 w-full justify-start',
isActive && 'bg-zinc-100'
),
})
);
}}
>
{title}
</NavLink>
</div>
);
};
@@ -1,21 +0,0 @@
import { createContext, useContext } from 'react';
interface NavContextType {
activeTab: string;
activeSubTab: string;
currentModule: string;
setActiveTab: (tab: string) => void;
setActiveSubTab: (tab: string) => void;
setCurrentModule: (module: string) => void;
}
export const NavContext = createContext<NavContextType | undefined>(undefined);
export const useNav = () => {
const context = useContext(NavContext);
if (!context) {
throw new Error('useNav must be used within a NavProvider');
}
return context;
};
@@ -0,0 +1,42 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, test } from 'vitest';
import { NavItem } from './nav-item';
describe('NavItem', () => {
afterEach(() => {
cleanup();
});
test('applies selected style when route matches', () => {
render(
<MemoryRouter initialEntries={['/admin/accounts']}>
<NavItem to="/admin/accounts" label="Accounts" icon={<span>i</span>} />
</MemoryRouter>
);
const link = screen.getByRole('link');
expect(link.className).toContain('bg-sidebar-active');
expect(link.className).toContain('text-sidebar-foreground');
});
test('keeps label hidden in collapsed mode', () => {
render(
<MemoryRouter initialEntries={['/admin/accounts']}>
<NavItem
to="/admin/dashboard"
label="Dashboard"
isCollapsed={true}
icon={<span>i</span>}
/>
</MemoryRouter>
);
expect(screen.queryByText('Dashboard')).toBeNull();
expect(screen.getByRole('link').className).toContain('w-9');
});
});
@@ -1,6 +1,4 @@
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 {
@@ -11,52 +9,34 @@ interface NavItemProps {
isCollapsed?: boolean;
}
const navItemBaseClass =
'group inline-flex h-9 items-center gap-2 rounded-lg text-sm font-medium transition-all duration-150';
const navItemStateClass =
'text-sidebar-foreground-secondary hover:bg-sidebar-hover hover:text-sidebar-foreground';
const navItemActiveClass =
'bg-sidebar-active text-sidebar-foreground shadow-sm';
export const NavItem = ({ icon, label, to, isCollapsed }: NavItemProps) => {
const className = ({ isActive }: { isActive: boolean }) =>
cn(
navItemBaseClass,
navItemStateClass,
isCollapsed ? 'w-9 justify-center px-0' : 'w-full justify-start px-2',
isActive && navItemActiveClass
);
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'),
},
})}
>
<NavLink to={to} className={className}>
{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 to={to} className={className}>
<span className="flex items-center p-0.5">{icon}</span>
<span className="truncate">{label}</span>
</NavLink>
);
};
@@ -1,77 +1,17 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
BarChart3Icon,
LayoutDashboardIcon,
ListChecksIcon,
} from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { NavItem } from './nav-item';
import { ServerVersion } from './server-version';
import { SettingsItem } from './settings-item';
import { UserDropdown } from './user-dropdown';
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 (
<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'),
},
})}
>
<span className="flex items-center p-0.5 mr-2">{icon}</span>
{label}
</NavLink>
);
};
interface NavProps {
isCollapsed?: boolean;
}
@@ -80,13 +20,13 @@ 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',
'flex h-full flex-grow flex-col justify-between gap-4 py-2',
isCollapsed && 'overflow-visible'
)}
>
<nav
className={cn(
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-y-auto overflow-x-hidden',
'flex flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto px-2',
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
@@ -134,7 +74,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
</nav>
<div
className={cn(
'flex gap-2 px-2 flex-col overflow-hidden',
'flex flex-col gap-2 overflow-hidden px-2',
isCollapsed && 'items-center px-0 gap-1'
)}
>
@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { Button } from '../../components/ui/button';
@@ -19,7 +18,7 @@ export const ServerVersion = () => {
return (
<Button
variant="outline"
className="flex items-center justify-center gap-1 text-xs p-2 font-medium w-full overflow-hidden"
className="flex w-full items-center justify-center gap-1 overflow-hidden px-2 py-1.5 text-xs font-medium"
onClick={handleClick}
title={`New Version ${availableUpgrade.version} Available`}
>
@@ -32,12 +31,7 @@ export const ServerVersion = () => {
);
}
return (
<div
className="inline-flex items-center justify-between pt-2 border-t px-2 text-xs flex-nowrap gap-1"
style={{
color: cssVarV2('text/tertiary'),
}}
>
<div className="inline-flex flex-nowrap items-center justify-between gap-1 border-t border-border px-2 pt-2 text-xs text-muted-foreground">
<span>ServerVersion</span>
<span
className="overflow-hidden text-ellipsis whitespace-nowrap"
@@ -1,198 +1,15 @@
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 { ROUTES } from '@affine/routes';
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 { NavLink } from 'react-router-dom';
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
import { NormalSubItem } from './collapsible-item';
import { useNav } from './context';
import { NavItem } from './nav-item';
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
const { setCurrentModule } = useNav();
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 fontSize={20} />
</NavLink>
</NavigationMenuPrimitive.Trigger>
<NavigationMenuPrimitive.Content>
<ul
className="border rounded-lg w-full flex flex-col p-1 min-w-[160px] max-h-[200px] overflow-y-auto"
style={{
backgroundColor: cssVarV2('layer/background/overlayPanel'),
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
}}
>
{KNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</NavLink>
</li>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<li className="flex px-2 pt-1 pb-0.5 text-xs font-medium opacity-70">
Experimental
</li>
) : null}
{UNKNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 pl-6 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</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 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">
<span className="flex items-center p-0.5 mr-2">
<SettingsIcon fontSize={20} />
</span>
<span>Settings</span>
</div>
</AccordionTrigger>
</NavLink>
<AccordionContent className="h-full overflow-hidden w-full pb-0">
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full h-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
{KNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
/>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
Experimental
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 py-1">
{UNKNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
indent="nested"
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
</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>
<NavItem
to={ROUTES.admin.settings.index}
icon={<SettingsIcon fontSize={20} />}
label="Settings"
isCollapsed={isCollapsed}
/>
);
};
@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu';
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { CircleUser } from 'lucide-react';
import { useCallback } from 'react';
import { toast } from 'sonner';
@@ -25,6 +24,9 @@ interface UserDropdownProps {
isCollapsed: boolean;
}
const adminBadgeClass =
'inline-flex h-5 items-center rounded-md border border-border/60 bg-chip-blue px-2 py-0.5 text-xxs font-medium text-chip-text';
const UserInfo = ({
name,
email,
@@ -44,21 +46,41 @@ const UserInfo = ({
</Avatar>
<div className="flex flex-col font-medium gap-1">
{name ?? email.split('@')[0]}
<span
className="w-fit rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/blue'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
Admin
</span>
<span className={adminBadgeClass}>Admin</span>
</div>
</>
);
};
const UserName = ({
name,
email,
}: {
name?: string | null;
email?: string;
}) => {
if (name) {
return (
<span
className="max-w-[120px] overflow-hidden text-ellipsis text-sm whitespace-nowrap"
title={name}
>
{name}
</span>
);
}
const fallback = email?.split('@')[0] ?? '';
return (
<span
className="max-w-[120px] overflow-hidden text-ellipsis text-sm whitespace-nowrap"
title={fallback}
>
{fallback}
</span>
);
};
export function UserDropdown({ isCollapsed }: UserDropdownProps) {
const currentUser = useCurrentUser();
const relative = useRevalidateCurrentUser();
@@ -78,8 +100,8 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-10 h-10" size="icon">
<Avatar className="w-5 h-5">
<Button variant="ghost" className="h-9 w-9 rounded-lg" size="icon">
<Avatar className="h-5 w-5">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
<AvatarFallback>
<CircleUser size={24} />
@@ -105,43 +127,24 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
}
return (
<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">
<div className="flex items-center justify-between px-1 py-3">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Avatar className="h-5 w-5">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
<AvatarFallback>
<CircleUser size={24} />
</AvatarFallback>
</Avatar>
{currentUser?.name ? (
<span
className="text-sm text-nowrap text-ellipsis break-words overflow-hidden"
title={currentUser?.name}
>
{currentUser?.name}
</span>
) : (
// Fallback to email prefix if name is not available
<span className="text-sm" title={currentUser?.email.split('@')[0]}>
{currentUser?.email.split('@')[0]}
</span>
)}
<span
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/blue'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
Admin
</span>
<UserName name={currentUser?.name} email={currentUser?.email} />
<span className={adminBadgeClass}>Admin</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-2 p-1 h-6">
<Button
variant="ghost"
className="ml-2 h-7 w-7 rounded-lg p-0"
size="icon"
>
<MoreVerticalIcon fontSize={20} />
</Button>
</DropdownMenuTrigger>