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

@@ -15,7 +15,7 @@ import {
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { useRightPanel } from '../../layout';
import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema';
import { DeleteAccountDialog } from './delete-account';
import { DiscardChanges } from './discard-changes';
@@ -31,8 +31,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const { setRightPanelContent, openPanel, isOpen, closePanel } =
useRightPanel();
const { openPanel, isOpen, closePanel, setPanelContent } = useRightPanel();
const deleteUser = useDeleteUser();
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
@@ -81,7 +80,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
}, []);
const handleConfirm = useCallback(() => {
setRightPanelContent(
setPanelContent(
<UpdateUserForm
user={user}
onComplete={closePanel}
@@ -92,7 +91,6 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
if (discardDialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel();
}
@@ -104,7 +102,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
openDeleteDialog,
openPanel,
openResetPasswordDialog,
setRightPanelContent,
setPanelContent,
user,
]);

View File

@@ -12,7 +12,7 @@ import {
useState,
} from 'react';
import { useRightPanel } from '../../layout';
import { useRightPanel } from '../../panel/context';
import { DiscardChanges } from './discard-changes';
import { CreateUserForm } from './user-form';
@@ -59,19 +59,18 @@ export function DataTableToolbar<TData>({
const [value, setValue] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 1000);
const { setRightPanelContent, openPanel, closePanel, isOpen } =
useRightPanel();
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
const { result, query } = useSearch();
const handleConfirm = useCallback(() => {
setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
setPanelContent(<CreateUserForm onComplete={closePanel} />);
if (dialogOpen) {
setDialogOpen(false);
}
if (!isOpen) {
openPanel();
}
}, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
}, [setPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
useEffect(() => {
query(debouncedValue);

View File

@@ -4,11 +4,12 @@ import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
import { ChevronRightIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import type { UserInput, UserType } from '../schema';
import { useCreateUser, useUpdateUser } from './use-user-management';
@@ -92,29 +93,12 @@ function UserForm({
return (
<div className="flex flex-col h-full gap-1">
<div className=" flex justify-between items-center py-[10px] px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">{title}</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<RightPanelHeader
title={title}
handleClose={handleClose}
handleConfirm={handleConfirm}
canSave={canSave}
/>
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<InputItem

View File

@@ -1,18 +1,13 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { Header } from '../header';
import { columns } from './components/columns';
import { DataTable } from './components/data-table';
import { useUserList } from './use-user-list';
export function AccountPage() {
const { users, pagination, setPagination } = useUserList();
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">Accounts</div>
</div>
<Separator />
<Header title="Accounts" />
<DataTable
data={users}

View File

@@ -1,11 +1,10 @@
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator';
import { Textarea } from '@affine/admin/components/ui/textarea';
import { CheckIcon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRightPanel } from '../layout';
import { RightPanelHeader } from '../header';
import { useRightPanel } from '../panel/context';
import type { Prompt } from './prompts';
import { usePrompt } from './use-prompt';
@@ -56,29 +55,12 @@ export function EditPrompt({
return (
<div className="flex flex-col h-full gap-1">
<div className="flex justify-between items-center py-[10px] px-6 ">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">Edit Prompt</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onConfirm}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<RightPanelHeader
title="Edit Prompt"
handleClose={handleClose}
handleConfirm={onConfirm}
canSave={!disableSave}
/>
<ScrollArea>
<div className="grid">
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">

View File

@@ -1,26 +1,42 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import { cn } from '@affine/admin/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { useState } from 'react';
import { Prompts } from './prompts';
import { Header } from '../header';
function AiPage() {
const [enableAi, setEnableAi] = useState(false);
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">AI</div>
</div>
<Separator />
<div className="h-screen flex-1 flex-col flex">
<Header title="AI" />
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
<Prompts />
<div className="p-6 max-w-3xl mx-auto">
<div className="text-[20px]">AI</div>
<div className="flex justify-between items-center">
<div>
<p className="text-[15px] font-medium mt-6">Enable AI</p>
<p className="text-sm text-muted-foreground mt-1">
AI functionality is not currently supported. Self-hosted AI
support is in progress.
</p>
</div>
<Switch
checked={enableAi}
onCheckedChange={setEnableAi}
disabled={true}
/>
</div>
</div>
{/* <Prompts /> */}
</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]'
)}
>

View File

@@ -3,7 +3,7 @@ import { Separator } from '@affine/admin/components/ui/separator';
import type { CopilotPromptMessageRole } from '@affine/graphql';
import { useCallback, useState } from 'react';
import { useRightPanel } from '../layout';
import { useRightPanel } from '../panel/context';
import { DiscardChanges } from './discard-changes';
import { EditPrompt } from './edit-prompt';
import { usePrompt } from './use-prompt';
@@ -52,7 +52,7 @@ export function Prompts() {
}
export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
const { setPanelContent, openPanel, isOpen } = useRightPanel();
const [dialogOpen, setDialogOpen] = useState(false);
const [canSave, setCanSave] = useState(false);
@@ -63,7 +63,7 @@ export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const handleConfirm = useCallback(
(item: Prompt) => {
setRightPanelContent(<EditPrompt item={item} setCanSave={setCanSave} />);
setPanelContent(<EditPrompt item={item} setCanSave={setCanSave} />);
if (dialogOpen) {
handleDiscardChangesCancel();
}
@@ -72,13 +72,7 @@ export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
openPanel();
}
},
[
dialogOpen,
handleDiscardChangesCancel,
isOpen,
openPanel,
setRightPanelContent,
]
[dialogOpen, handleDiscardChangesCancel, isOpen, openPanel, setPanelContent]
);
const handleEdit = useCallback(

View File

@@ -4,6 +4,7 @@ import {
FeatureType,
getCurrentUserFeaturesQuery,
} from '@affine/graphql';
import { useEffect, useState } from 'react';
import { useMutateQueryResource } from '../use-mutation';
import { useQuery } from '../use-query';
@@ -43,3 +44,21 @@ export function isAdmin(
) {
return user.features.includes(FeatureType.Admin);
}
export function useMediaQuery(query: string) {
const [value, setValue] = useState(false);
useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches);
}
const result = matchMedia(query);
result.addEventListener('change', onChange);
setValue(result.matches);
return () => result.removeEventListener('change', onChange);
}, [query]);
return value;
}

View File

@@ -5,8 +5,8 @@ import {
CardTitle,
} from '@affine/admin/components/ui/card';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator';
import { Header } from '../header';
import { AboutAFFiNE } from './about';
import type {
DatabaseConfig,
@@ -18,10 +18,7 @@ import { useServerServiceConfigs } from './use-server-service-configs';
export function ConfigPage() {
return (
<div className=" h-screen flex-1 space-y-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
<div className="text-base font-medium">Config</div>
</div>
<Separator />
<Header title="Server" />
<ScrollArea>
<ServerServiceConfig />
<AboutAFFiNE />

View File

@@ -0,0 +1,81 @@
import { SidebarIcon } from '@blocksuite/icons/rc';
import { CheckIcon, XIcon } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Separator } from '../components/ui/separator';
import { useMediaQuery } from './common';
import { useLeftPanel } from './panel/context';
export const Header = ({
title,
endFix,
}: {
title: string;
endFix?: React.ReactNode;
}) => {
const { togglePanel } = useLeftPanel();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
<div>
<div className="flex items-center px-6 gap-4 h-[56px]">
{isSmallScreen ? (
<div className="h-7 w-7 p-1" />
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-1 hover:bg-gray-200 cursor-pointer"
onClick={togglePanel}
>
<SidebarIcon width={20} height={20} />
</Button>
)}
<Separator orientation="vertical" className="h-5" />
<div className="text-[15px] font-semibold">{title}</div>
{endFix && <div className="ml-auto">{endFix}</div>}
</div>
<Separator />
</div>
);
};
export const RightPanelHeader = ({
title,
handleClose,
handleConfirm,
canSave,
}: {
title: string;
handleClose: () => void;
handleConfirm: () => void;
canSave: boolean;
}) => {
return (
<div>
<div className=" flex justify-between items-center h-[56px] px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">{title}</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
</div>
);
};

View File

@@ -1,21 +1,14 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@affine/admin/components/ui/resizable';
import { Separator } from '@affine/admin/components/ui/separator';
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
import { cn } from '@affine/admin/utils';
import { cssVarV2 } from '@toeverything/theme/v2';
import { AlignJustifyIcon } from 'lucide-react';
import type { PropsWithChildren, ReactNode, RefObject } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { Button } from '../components/ui/button';
@@ -28,97 +21,112 @@ import {
SheetTrigger,
} from '../components/ui/sheet';
import { Logo } from './accounts/components/logo';
import { useMediaQuery } from './common';
import { NavContext } from './nav/context';
import { Nav } from './nav/nav';
interface RightPanelContextType {
isOpen: boolean;
rightPanelContent: ReactNode;
setRightPanelContent: (content: ReactNode) => void;
togglePanel: () => void;
openPanel: () => void;
closePanel: () => void;
}
const RightPanelContext = createContext<RightPanelContextType | undefined>(
undefined
);
export const useRightPanel = () => {
const context = useContext(RightPanelContext);
if (!context) {
throw new Error('useRightPanel must be used within a RightPanelProvider');
}
return context;
};
export function useMediaQuery(query: string) {
const [value, setValue] = useState(false);
useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches);
}
const result = matchMedia(query);
result.addEventListener('change', onChange);
setValue(result.matches);
return () => result.removeEventListener('change', onChange);
}, [query]);
return value;
}
import {
PanelContext,
type ResizablePanelProps,
useRightPanel,
} from './panel/context';
export function Layout({ children }: PropsWithChildren) {
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
const [open, setOpen] = useState(false);
const [leftPanelContent, setLeftPanelContent] = useState<ReactNode>(null);
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null);
const leftPanelRef = useRef<ImperativePanelHandle>(null);
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('auth');
const [currentModule, setCurrentModule] = useState('auth');
const handleExpand = useCallback(() => {
const handleLeftExpand = useCallback(() => {
if (leftPanelRef.current?.getSize() === 0) {
leftPanelRef.current?.resize(30);
}
setLeftOpen(true);
}, [leftPanelRef]);
const handleLeftCollapse = useCallback(() => {
if (leftPanelRef.current?.getSize() !== 0) {
leftPanelRef.current?.resize(0);
}
setLeftOpen(false);
}, [leftPanelRef]);
const openLeftPanel = useCallback(() => {
handleLeftExpand();
leftPanelRef.current?.expand();
setLeftOpen(true);
}, [handleLeftExpand]);
const closeLeftPanel = useCallback(() => {
handleLeftCollapse();
leftPanelRef.current?.collapse();
setLeftOpen(false);
}, [handleLeftCollapse]);
const toggleLeftPanel = useCallback(
() =>
leftPanelRef.current?.isCollapsed() ? openLeftPanel() : closeLeftPanel(),
[openLeftPanel, closeLeftPanel]
);
const handleRightExpand = useCallback(() => {
if (rightPanelRef.current?.getSize() === 0) {
rightPanelRef.current?.resize(30);
}
setOpen(true);
setRightOpen(true);
}, [rightPanelRef]);
const handleCollapse = useCallback(() => {
const handleRightCollapse = useCallback(() => {
if (rightPanelRef.current?.getSize() !== 0) {
rightPanelRef.current?.resize(0);
}
setOpen(false);
setRightOpen(false);
}, [rightPanelRef]);
const openPanel = useCallback(() => {
handleExpand();
const openRightPanel = useCallback(() => {
handleRightExpand();
rightPanelRef.current?.expand();
}, [handleExpand]);
setRightOpen(true);
}, [handleRightExpand]);
const closePanel = useCallback(() => {
handleCollapse();
const closeRightPanel = useCallback(() => {
handleRightCollapse();
rightPanelRef.current?.collapse();
}, [handleCollapse]);
setRightOpen(false);
}, [handleRightCollapse]);
const togglePanel = useCallback(
() => (rightPanelRef.current?.isCollapsed() ? openPanel() : closePanel()),
[closePanel, openPanel]
const toggleRightPanel = useCallback(
() =>
rightPanelRef.current?.isCollapsed()
? openRightPanel()
: closeRightPanel(),
[closeRightPanel, openRightPanel]
);
return (
<RightPanelContext.Provider
<PanelContext.Provider
value={{
isOpen: open,
rightPanelContent,
setRightPanelContent,
togglePanel,
openPanel,
closePanel,
leftPanel: {
isOpen: leftOpen,
panelContent: leftPanelContent,
setPanelContent: setLeftPanelContent,
togglePanel: toggleLeftPanel,
openPanel: openLeftPanel,
closePanel: closeLeftPanel,
},
rightPanel: {
isOpen: rightOpen,
panelContent: rightPanelContent,
setPanelContent: setRightPanelContent,
togglePanel: toggleRightPanel,
openPanel: openRightPanel,
closePanel: closeRightPanel,
},
}}
>
<NavContext.Provider
@@ -133,34 +141,41 @@ export function Layout({ children }: PropsWithChildren) {
>
<TooltipProvider delayDuration={0}>
<div className="flex">
<LeftPanel />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel id="0" order={0} minSize={50}>
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleLeftExpand}
onCollapse={handleLeftCollapse}
/>
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
{children}
</ResizablePanel>
<RightPanel
rightPanelRef={
rightPanelRef as RefObject<ImperativePanelHandle>
}
onExpand={handleExpand}
onCollapse={handleCollapse}
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleRightExpand}
onCollapse={handleRightCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</NavContext.Provider>
</RightPanelContext.Provider>
</PanelContext.Provider>
);
}
export const LeftPanel = () => {
export const LeftPanel = ({
panelRef,
onExpand,
onCollapse,
}: ResizablePanelProps) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const isCollapsed = panelRef.current?.isCollapsed();
if (isSmallScreen) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" className="fixed top-4 left-6 p-0 h-5 w-5">
<Button variant="ghost" className="fixed top-5 left-6 p-0 h-5 w-5">
<AlignJustifyIcon size={20} />
</Button>
</SheetTrigger>
@@ -189,31 +204,52 @@ export const LeftPanel = () => {
}
return (
<div className="flex flex-col min-w-52 max-w-sm border-r">
<ResizablePanel
id="0"
order={0}
ref={panelRef}
defaultSize={15}
maxSize={15}
minSize={15}
collapsible={true}
collapsedSize={2}
onExpand={onExpand}
onCollapse={onCollapse}
className={cn(
isCollapsed ? 'min-w-[56px] max-w-[56px]' : 'min-w-56 max-w-56',
'border-r h-dvh'
)}
style={{ overflow: 'visible' }}
>
<div
className={cn(
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
)}
className="flex flex-col max-w-56 h-full "
style={{
backgroundColor: cssVarV2(
'selfhost/layer/background/sidebarBg/sidebarBg'
),
}}
>
<Logo />
AFFiNE
<div
className={cn(
'flex h-[56px] items-center gap-2 px-4 text-base font-medium',
isCollapsed && 'justify-center px-2'
)}
>
<Logo />
{!isCollapsed && 'AFFiNE'}
</div>
<Nav isCollapsed={isCollapsed} />
</div>
<Separator />
<Nav />
</div>
</ResizablePanel>
);
};
export const RightPanel = ({
rightPanelRef,
panelRef,
onExpand,
onCollapse,
}: {
rightPanelRef: RefObject<ImperativePanelHandle>;
onExpand: () => void;
onCollapse: () => void;
}) => {
}: ResizablePanelProps) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { rightPanelContent, isOpen } = useRightPanel();
const { panelContent, isOpen } = useRightPanel();
const onOpenChange = useCallback(
(open: boolean) => {
if (open) {
@@ -235,28 +271,26 @@ export const RightPanel = ({
</SheetDescription>
</SheetHeader>
<SheetContent side="right" className="p-0" withoutCloseButton>
{rightPanelContent}
{panelContent}
</SheetContent>
</Sheet>
);
}
return (
<>
{isOpen ? <ResizableHandle /> : null}
<ResizablePanel
id="1"
order={1}
ref={rightPanelRef}
defaultSize={0}
maxSize={30}
collapsible={true}
collapsedSize={0}
onExpand={onExpand}
onCollapse={onCollapse}
>
{rightPanelContent}
</ResizablePanel>
</>
<ResizablePanel
id="2"
order={2}
ref={panelRef}
defaultSize={0}
maxSize={30}
collapsible={true}
collapsedSize={0}
onExpand={onExpand}
onCollapse={onCollapse}
className="border-l"
>
{panelContent}
</ResizablePanel>
);
};

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>

View File

@@ -0,0 +1,51 @@
import {
createContext,
type ReactNode,
type RefObject,
useContext,
} from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
export type SinglePanelContextType = {
isOpen: boolean;
panelContent: ReactNode;
setPanelContent: (content: ReactNode) => void;
togglePanel: () => void;
openPanel: () => void;
closePanel: () => void;
};
export interface PanelContextType {
leftPanel: SinglePanelContextType;
rightPanel: SinglePanelContextType;
}
export type ResizablePanelProps = {
panelRef: RefObject<ImperativePanelHandle>;
onExpand: () => void;
onCollapse: () => void;
};
export const PanelContext = createContext<PanelContextType | undefined>(
undefined
);
export const usePanelContext = () => {
const context = useContext(PanelContext);
if (!context) {
throw new Error('usePanelContext must be used within a PanelProvider');
}
return context;
};
export const useLeftPanel = () => {
const context = usePanelContext();
return context.leftPanel;
};
export const useRightPanel = () => {
const context = usePanelContext();
return context.rightPanel;
};

View File

@@ -6,6 +6,7 @@ import { CheckIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Header } from '../header';
import { useNav } from '../nav/context';
import { ConfirmChanges } from './confirm-changes';
import { RuntimeSettingRow } from './runtime-setting-row';
@@ -71,20 +72,21 @@ export function SettingsPage() {
}, [disableSave, handleSave, onClose]);
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
<div className="text-base font-medium">Settings</div>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onOpen}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<Header
title="Settings"
endFix={
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onOpen}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
}
/>
<AdminPanel
configValues={configValues}
setConfigValues={setConfigValues}
@@ -124,7 +126,7 @@ export const AdminPanel = ({
return (
<ScrollArea>
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full max-w-[800px] mx-auto">
{configGroup
.filter(group => group.moduleName === currentModule)
.map(group => {