mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(admin): make the left navigation bar collapsable (#10774)
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
81
packages/frontend/admin/src/modules/header.tsx
Normal file
81
packages/frontend/admin/src/modules/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
packages/frontend/admin/src/modules/panel/context.ts
Normal file
51
packages/frontend/admin/src/modules/panel/context.ts
Normal 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;
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user