-
+
-
+
);
};
diff --git a/packages/frontend/admin/src/modules/nav/collapsible-item.tsx b/packages/frontend/admin/src/modules/nav/collapsible-item.tsx
new file mode 100644
index 0000000000..c3c4102223
--- /dev/null
+++ b/packages/frontend/admin/src/modules/nav/collapsible-item.tsx
@@ -0,0 +1,67 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from '@affine/admin/components/ui/accordion';
+import { useCallback } from 'react';
+import { Link } from 'react-router-dom';
+
+import { useNav } from './context';
+
+export const CollapsibleItem = ({
+ items,
+ title,
+ changeModule,
+}: {
+ title: string;
+ items: string[];
+ changeModule?: (module: string) => void;
+}) => {
+ const { activeSubTab, setActiveSubTab } = useNav();
+ const handleClick = useCallback(
+ (id: string) => {
+ const targetElement = document.getElementById(id);
+ if (targetElement) {
+ targetElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ }
+ changeModule?.(title);
+ setActiveSubTab(id);
+ },
+ [changeModule, setActiveSubTab, title]
+ );
+
+ return (
+
+
+
+ handleClick(title)}
+ className={`py-2 px-3 rounded ${activeSubTab === title ? 'bg-zinc-100' : ''}`}
+ >
+ {title}
+
+
+
+ {items.map((item, index) => (
+
+ handleClick(item)}
+ className={`py-1 px-2 rounded text-ellipsis whitespace-nowrap overflow-hidden ${activeSubTab === item ? 'bg-zinc-100' : ''}`}
+ >
+ {item}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/frontend/admin/src/modules/nav/context.ts b/packages/frontend/admin/src/modules/nav/context.ts
new file mode 100644
index 0000000000..ef50f617a7
--- /dev/null
+++ b/packages/frontend/admin/src/modules/nav/context.ts
@@ -0,0 +1,21 @@
+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
(undefined);
+export const useNav = () => {
+ const context = useContext(NavContext);
+
+ if (!context) {
+ throw new Error('useNav must be used within a NavProvider');
+ }
+
+ return context;
+};
diff --git a/packages/frontend/admin/src/modules/nav/index.tsx b/packages/frontend/admin/src/modules/nav/index.tsx
deleted file mode 100644
index 9c30202007..0000000000
--- a/packages/frontend/admin/src/modules/nav/index.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { Button } from '@affine/admin/components/ui/button';
-import {
- Sheet,
- SheetContent,
- SheetTrigger,
-} from '@affine/admin/components/ui/sheet';
-import { Menu, Package2 } from 'lucide-react';
-import type { PropsWithChildren } from 'react';
-import { Link } from 'react-router-dom';
-
-import { UserDropdown } from './user-dropdown';
-
-export function Nav({ children }: PropsWithChildren) {
- return (
-
-
-
-
-
-
-
- Toggle navigation menu
-
-
-
-
-
-
-
-
-
-
- {children}
-
- );
-}
diff --git a/packages/frontend/admin/src/modules/nav/nav.tsx b/packages/frontend/admin/src/modules/nav/nav.tsx
index f22d2a4a7f..3efcaaa49b 100644
--- a/packages/frontend/admin/src/modules/nav/nav.tsx
+++ b/packages/frontend/admin/src/modules/nav/nav.tsx
@@ -1,56 +1,158 @@
+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 type { LucideIcon } from 'lucide-react';
+import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
+import {
+ ClipboardListIcon,
+ CpuIcon,
+ SettingsIcon,
+ UsersIcon,
+} from 'lucide-react';
+import { useEffect } from 'react';
import { Link } from 'react-router-dom';
+import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
+import { CollapsibleItem } from './collapsible-item';
+import { useNav } from './context';
import { UserDropdown } from './user-dropdown';
-export interface NavProp {
- title: string;
- to: string;
- label?: string;
- icon: LucideIcon;
-}
+const TabsMap: { [key: string]: string } = {
+ accounts: 'Accounts',
+ ai: 'AI',
+ config: 'Config',
+ settings: 'Settings',
+};
+
+const defaultTab = 'Accounts';
+
+export function Nav() {
+ const { moduleList } = useGetServerRuntimeConfig();
+ const { activeTab, setActiveTab, setCurrentModule } = useNav();
+
+ useEffect(() => {
+ const path = window.location.pathname;
+ for (const key in TabsMap) {
+ if (path.includes(key)) {
+ setActiveTab(TabsMap[key]);
+ return;
+ }
+ }
+ setActiveTab(defaultTab);
+ }, [setActiveTab]);
-export function Nav({
- links,
- activeTab,
-}: {
- links: NavProp[];
- activeTab: string;
-}) {
return (
-
-
);
diff --git a/packages/frontend/admin/src/modules/nav/user-dropdown.tsx b/packages/frontend/admin/src/modules/nav/user-dropdown.tsx
index 00673d5136..6ed74d3868 100644
--- a/packages/frontend/admin/src/modules/nav/user-dropdown.tsx
+++ b/packages/frontend/admin/src/modules/nav/user-dropdown.tsx
@@ -67,7 +67,7 @@ export function UserDropdown() {
}, [currentUser, navigate, serverConfig.initialized]);
return (
-
+
diff --git a/packages/frontend/admin/src/modules/settings/confirm-changes.tsx b/packages/frontend/admin/src/modules/settings/confirm-changes.tsx
new file mode 100644
index 0000000000..1e81c21321
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/confirm-changes.tsx
@@ -0,0 +1,81 @@
+import { Button } from '@affine/admin/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@affine/admin/components/ui/dialog';
+
+import type { ModifiedValues } from './index';
+
+export const ConfirmChanges = ({
+ open,
+ onClose,
+ onConfirm,
+ onOpenChange,
+ modifiedValues,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ onOpenChange: (open: boolean) => void;
+ modifiedValues: ModifiedValues[];
+}) => {
+ return (
+
+ );
+};
diff --git a/packages/frontend/admin/src/modules/settings/index.tsx b/packages/frontend/admin/src/modules/settings/index.tsx
new file mode 100644
index 0000000000..6aa25dd2f9
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/index.tsx
@@ -0,0 +1,193 @@
+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 type { RuntimeConfigType } from '@affine/graphql';
+import { CheckIcon } from 'lucide-react';
+import type { Dispatch, SetStateAction } from 'react';
+import { useCallback, useMemo, useState } from 'react';
+
+import { Layout } from '../layout';
+import { useNav } from '../nav/context';
+import { ConfirmChanges } from './confirm-changes';
+import { RuntimeSettingRow } from './runtime-setting-row';
+import { useGetServerRuntimeConfig } from './use-get-server-runtime-config';
+import { useUpdateServerRuntimeConfigs } from './use-update-server-runtime-config';
+import {
+ formatValue,
+ formatValueForInput,
+ isEqual,
+ renderInput,
+} from './utils';
+
+export type ModifiedValues = {
+ id: string;
+ expiredValue: any;
+ newValue: any;
+};
+
+export function Settings() {
+ return } />;
+}
+
+export function SettingsPage() {
+ const { trigger } = useUpdateServerRuntimeConfigs();
+ const { serverRuntimeConfig } = useGetServerRuntimeConfig();
+ const [open, setOpen] = useState(false);
+ const [configValues, setConfigValues] = useState(
+ serverRuntimeConfig.reduce(
+ (acc, config) => {
+ acc[config.id] = config.value;
+ return acc;
+ },
+ {} as Record
+ )
+ );
+ const modifiedValues: ModifiedValues[] = useMemo(() => {
+ return serverRuntimeConfig
+ .filter(config => !isEqual(config.value, configValues[config.id]))
+ .map(config => ({
+ id: config.id,
+ key: config.key,
+ expiredValue: config.value,
+ newValue: configValues[config.id],
+ }));
+ }, [configValues, serverRuntimeConfig]);
+ const handleSave = useCallback(() => {
+ // post value example: { "key1": "newValue1","key2": "newValue2"}
+ const updates: Record = {};
+
+ modifiedValues.forEach(item => {
+ if (item.id && item.newValue !== undefined) {
+ updates[item.id] = item.newValue;
+ }
+ });
+ trigger({ updates });
+ }, [modifiedValues, trigger]);
+
+ const disableSave = modifiedValues.length === 0;
+ const onOpen = useCallback(() => setOpen(true), [setOpen]);
+ const onClose = useCallback(() => setOpen(false), [setOpen]);
+ const onConfirm = useCallback(() => {
+ if (disableSave) {
+ return;
+ }
+ handleSave();
+ onClose();
+ }, [disableSave, handleSave, onClose]);
+ return (
+
+ );
+}
+
+export const AdminPanel = ({
+ setConfigValues,
+ configValues,
+}: {
+ setConfigValues: Dispatch>>;
+ configValues: Record;
+}) => {
+ const { configGroup } = useGetServerRuntimeConfig();
+
+ const { currentModule } = useNav();
+
+ const handleInputChange = useCallback(
+ (key: string, value: any, type: RuntimeConfigType) => {
+ const newValue = formatValueForInput(value, type);
+ setConfigValues(prevValues => ({
+ ...prevValues,
+ [key]: newValue,
+ }));
+ },
+ [setConfigValues]
+ );
+
+ return (
+
+
+ {configGroup
+ .filter(group => group.moduleName === currentModule)
+ .map(group => {
+ const { moduleName, configs } = group;
+ return (
+
+
{moduleName}
+ {configs?.map((config, index) => {
+ const { id, type, description, updatedAt } = config;
+ const isValueEqual = isEqual(config.value, configValues[id]);
+ const formatServerValue = formatValue(config.value);
+ const formatCurrentValue = formatValue(configValues[id]);
+ return (
+
+ {index !== 0 &&
}
+
+ handleInputChange(id, value, type)
+ )}
+ >
+
+
+ {formatServerValue}
+ {' '}
+ =>{' '}
+
+ {formatCurrentValue}
+
+
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+ );
+};
+
+export { Settings as Component };
diff --git a/packages/frontend/admin/src/modules/settings/runtime-setting-row.tsx b/packages/frontend/admin/src/modules/settings/runtime-setting-row.tsx
new file mode 100644
index 0000000000..db55194de1
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/runtime-setting-row.tsx
@@ -0,0 +1,39 @@
+import { type ReactNode } from 'react';
+
+export const RuntimeSettingRow = ({
+ id,
+ description,
+ lastUpdatedTime,
+ operation,
+ children,
+}: {
+ id: string;
+ description: string;
+ lastUpdatedTime: string;
+ operation: ReactNode;
+ children: ReactNode;
+}) => {
+ const formatTime = new Date(lastUpdatedTime).toLocaleString();
+ return (
+
+
+
{description}
+
+
+ {id}
+
+
+
+ last updated at: {formatTime}
+
+
+
+ {operation}
+ {children}
+
+
+ );
+};
diff --git a/packages/frontend/admin/src/modules/settings/use-get-server-runtime-config.ts b/packages/frontend/admin/src/modules/settings/use-get-server-runtime-config.ts
new file mode 100644
index 0000000000..333d3604fe
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/use-get-server-runtime-config.ts
@@ -0,0 +1,57 @@
+import { useQuery } from '@affine/core/hooks/use-query';
+import { getServerRuntimeConfigQuery } from '@affine/graphql';
+import { useMemo } from 'react';
+
+export const useGetServerRuntimeConfig = () => {
+ const { data } = useQuery({
+ query: getServerRuntimeConfigQuery,
+ });
+
+ const serverRuntimeConfig = useMemo(
+ () =>
+ data?.serverRuntimeConfig.sort((a, b) => a.id.localeCompare(b.id)) ?? [],
+ [data]
+ );
+
+ // collect all the modules and config keys in each module
+ const moduleList = useMemo(() => {
+ const moduleMap: { [key: string]: string[] } = {};
+
+ serverRuntimeConfig.forEach(config => {
+ if (!moduleMap[config.module]) {
+ moduleMap[config.module] = [];
+ }
+ moduleMap[config.module].push(config.key);
+ });
+
+ return Object.keys(moduleMap)
+ .sort((a, b) => a.localeCompare(b))
+ .map(moduleName => ({
+ moduleName,
+ keys: moduleMap[moduleName].sort((a, b) => a.localeCompare(b)),
+ }));
+ }, [serverRuntimeConfig]);
+
+ // group config by module name
+ const configGroup = useMemo(() => {
+ const configMap = new Map();
+
+ serverRuntimeConfig.forEach(config => {
+ if (!configMap.has(config.module)) {
+ configMap.set(config.module, []);
+ }
+ configMap.get(config.module)?.push(config);
+ });
+
+ return Array.from(configMap.entries()).map(([moduleName, configs]) => ({
+ moduleName,
+ configs,
+ }));
+ }, [serverRuntimeConfig]);
+
+ return {
+ serverRuntimeConfig,
+ moduleList,
+ configGroup,
+ };
+};
diff --git a/packages/frontend/admin/src/modules/settings/use-update-server-runtime-config.ts b/packages/frontend/admin/src/modules/settings/use-update-server-runtime-config.ts
new file mode 100644
index 0000000000..ac3ee7f819
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/use-update-server-runtime-config.ts
@@ -0,0 +1,41 @@
+import { notify } from '@affine/component';
+import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
+import {
+ useMutateQueryResource,
+ useMutation,
+} from '@affine/core/hooks/use-mutation';
+import {
+ getServerRuntimeConfigQuery,
+ updateServerRuntimeConfigsMutation,
+} from '@affine/graphql';
+
+export const useUpdateServerRuntimeConfigs = () => {
+ const { trigger, isMutating } = useMutation({
+ mutation: updateServerRuntimeConfigsMutation,
+ });
+ const revalidate = useMutateQueryResource();
+
+ return {
+ trigger: useAsyncCallback(
+ async (values: any) => {
+ try {
+ await trigger(values);
+ await revalidate(getServerRuntimeConfigQuery);
+ notify.success({
+ title: 'Saved successfully',
+ message: 'Runtime configurations have been saved successfully.',
+ });
+ } catch (e) {
+ notify.error({
+ title: 'Failed to save',
+ message:
+ 'Failed to save runtime configurations, please try again later.',
+ });
+ console.error(e);
+ }
+ },
+ [revalidate, trigger]
+ ),
+ isMutating,
+ };
+};
diff --git a/packages/frontend/admin/src/modules/settings/utils.tsx b/packages/frontend/admin/src/modules/settings/utils.tsx
new file mode 100644
index 0000000000..fd69ef25ff
--- /dev/null
+++ b/packages/frontend/admin/src/modules/settings/utils.tsx
@@ -0,0 +1,73 @@
+import { Input } from '@affine/admin/components/ui/input';
+import { Switch } from '@affine/admin/components/ui/switch';
+import type { RuntimeConfigType } from '@affine/graphql';
+
+export const renderInput = (
+ type: RuntimeConfigType,
+ value: any,
+ onChange: (value?: any) => void
+) => {
+ const handleInputChange = (e: React.ChangeEvent) => {
+ onChange(e.target.value);
+ };
+ const handleSwitchChange = (checked: boolean) => {
+ onChange(checked);
+ };
+ switch (type) {
+ case 'Boolean':
+ return ;
+ case 'String':
+ return (
+
+ );
+ case 'Number':
+ return (
+
+
+
+ );
+ // TODO(@JimmFly): add more types
+ default:
+ return null;
+ }
+};
+
+export const isEqual = (a: any, b: any) => {
+ if (typeof a !== typeof b) return false;
+ if (typeof a === 'object') return JSON.stringify(a) === JSON.stringify(b);
+ return a === b;
+};
+
+export const formatValue = (value: any) => {
+ if (typeof value === 'object') return JSON.stringify(value);
+ return value.toString();
+};
+
+export const formatValueForInput = (value: any, type: RuntimeConfigType) => {
+ let newValue = null;
+ switch (type) {
+ case 'Boolean':
+ newValue = !!value;
+ break;
+ case 'String':
+ newValue = value;
+ break;
+ case 'Number':
+ newValue = Number(value);
+ break;
+ case 'Array':
+ newValue = value.split(',');
+ break;
+ case 'Object':
+ newValue = JSON.parse(value);
+ break;
+ default:
+ break;
+ }
+ return newValue;
+};