mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
feat: improve admin dashboard & settings
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { get } from 'lodash-es';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { useNav } from '../nav/context';
|
||||
import {
|
||||
ALL_CONFIG_DESCRIPTORS,
|
||||
ALL_SETTING_GROUPS,
|
||||
@@ -15,104 +19,237 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
|
||||
import { useAppConfig } from './use-app-config';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
|
||||
const disableSave = Object.keys(updates).length === 0;
|
||||
|
||||
const saveChanges = useCallback(() => {
|
||||
if (disableSave) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
}, [save, disableSave]);
|
||||
const {
|
||||
appConfig,
|
||||
update,
|
||||
saveGroup,
|
||||
resetGroup,
|
||||
patchedAppConfig,
|
||||
isGroupDirty,
|
||||
isGroupSaving,
|
||||
getGroupVersion,
|
||||
} = useAppConfig();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex">
|
||||
<Header
|
||||
title="Settings"
|
||||
endFix={
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={saveChanges}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Header title="Settings" />
|
||||
<AdminPanel
|
||||
expandedModules={expandedModules}
|
||||
onExpandedModulesChange={setExpandedModules}
|
||||
onUpdate={update}
|
||||
appConfig={appConfig}
|
||||
patchedAppConfig={patchedAppConfig}
|
||||
onSaveGroup={saveGroup}
|
||||
onResetGroup={resetGroup}
|
||||
isGroupDirty={isGroupDirty}
|
||||
isGroupSaving={isGroupSaving}
|
||||
getGroupVersion={getGroupVersion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AdminPanel = ({
|
||||
expandedModules,
|
||||
onExpandedModulesChange,
|
||||
appConfig,
|
||||
patchedAppConfig,
|
||||
onUpdate,
|
||||
onSaveGroup,
|
||||
onResetGroup,
|
||||
isGroupDirty,
|
||||
isGroupSaving,
|
||||
getGroupVersion,
|
||||
}: {
|
||||
expandedModules: string[];
|
||||
onExpandedModulesChange: (modules: string[]) => void;
|
||||
appConfig: AppConfig;
|
||||
patchedAppConfig: AppConfig;
|
||||
onUpdate: (path: string, value: any) => void;
|
||||
onSaveGroup: (module: string) => Promise<void>;
|
||||
onResetGroup: (module: string) => void;
|
||||
isGroupDirty: (module: string) => boolean;
|
||||
isGroupSaving: (module: string) => boolean;
|
||||
getGroupVersion: (module: string) => number;
|
||||
}) => {
|
||||
const { currentModule } = useNav();
|
||||
const group = ALL_SETTING_GROUPS.find(
|
||||
group => group.module === currentModule
|
||||
);
|
||||
const [groupErrors, setGroupErrors] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const onFieldErrorChange = useCallback((field: string, error?: string) => {
|
||||
const [module] = field.split('/');
|
||||
if (!module) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, module, fields, operations } = group;
|
||||
setGroupErrors(prev => {
|
||||
const moduleErrors = prev[module] ?? {};
|
||||
|
||||
if (error) {
|
||||
if (moduleErrors[field] === error) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[module]: {
|
||||
...moduleErrors,
|
||||
[field]: error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!(field in moduleErrors)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextModuleErrors = { ...moduleErrors };
|
||||
delete nextModuleErrors[field];
|
||||
|
||||
if (Object.keys(nextModuleErrors).length === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[module];
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[module]: nextModuleErrors,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearModuleErrors = useCallback((module: string) => {
|
||||
setGroupErrors(prev => {
|
||||
if (!prev[module]) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev };
|
||||
delete next[module];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
|
||||
<div className="text-2xl font-semibold">{name}</div>
|
||||
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
|
||||
{fields.map(field => {
|
||||
let desc: string;
|
||||
let props: ConfigInputProps;
|
||||
if (typeof field === 'string') {
|
||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
|
||||
desc = descriptor.desc;
|
||||
props = {
|
||||
field: `${module}/${field}`,
|
||||
desc,
|
||||
type: descriptor.type,
|
||||
options: [],
|
||||
defaultValue: get(appConfig[module], field),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
} else {
|
||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
|
||||
<div className="flex flex-col gap-4 py-5 px-6 w-full max-w-[900px] mx-auto">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
value={expandedModules}
|
||||
onValueChange={onExpandedModulesChange}
|
||||
>
|
||||
{ALL_SETTING_GROUPS.map(group => {
|
||||
const { name, module, fields, operations } = group;
|
||||
const dirty = isGroupDirty(module);
|
||||
const saving = isGroupSaving(module);
|
||||
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
|
||||
const version = getGroupVersion(module);
|
||||
const hasValidationError = Boolean(
|
||||
groupErrors[module] &&
|
||||
Object.keys(groupErrors[module] ?? {}).length > 0
|
||||
);
|
||||
|
||||
props = {
|
||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
||||
desc: field.desc ?? descriptor.desc,
|
||||
type: field.type ?? descriptor.type,
|
||||
// @ts-expect-error for enum type
|
||||
options: field.options,
|
||||
defaultValue: get(
|
||||
appConfig[module],
|
||||
field.key + (field.sub ? '.' + field.sub : '')
|
||||
),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<AccordionItem
|
||||
key={module}
|
||||
value={module}
|
||||
id={`config-module-${module}`}
|
||||
className="border border-border rounded-xl px-5 mb-4"
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex flex-col items-start text-left gap-1">
|
||||
<div className="text-lg font-semibold">{name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Manage {name.toLowerCase()} settings
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
return <ConfigRow key={props.field} {...props} />;
|
||||
<AccordionContent className="pt-2 pb-2 px-1">
|
||||
<div
|
||||
className="flex flex-col gap-8"
|
||||
key={`${module}-${version}`}
|
||||
>
|
||||
{fields.map(field => {
|
||||
let props: ConfigInputProps;
|
||||
if (typeof field === 'string') {
|
||||
const descriptor =
|
||||
ALL_CONFIG_DESCRIPTORS[module][field];
|
||||
props = {
|
||||
field: `${module}/${field}`,
|
||||
desc: descriptor.desc,
|
||||
type: descriptor.type,
|
||||
options: [],
|
||||
defaultValue: get(sourceConfig, field),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
} else {
|
||||
const descriptor =
|
||||
ALL_CONFIG_DESCRIPTORS[module][field.key];
|
||||
props = {
|
||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
||||
desc: field.desc ?? descriptor.desc,
|
||||
type: field.type ?? descriptor.type,
|
||||
// @ts-expect-error for enum type
|
||||
options: field.options,
|
||||
defaultValue: get(
|
||||
sourceConfig,
|
||||
field.key + (field.sub ? '.' + field.sub : '')
|
||||
),
|
||||
onChange: onUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigRow
|
||||
key={props.field}
|
||||
{...props}
|
||||
onErrorChange={onFieldErrorChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{operations?.map(Operation => (
|
||||
<Operation
|
||||
key={Operation.name}
|
||||
appConfig={patchedAppConfig}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{dirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onResetGroup(module);
|
||||
clearModuleErrors(module);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSaveGroup(module).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
disabled={!dirty || saving || hasValidationError}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
{operations?.map(Operation => (
|
||||
<Operation key={Operation.name} appConfig={patchedAppConfig} />
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user