Files
AFFiNE-Mirror/packages/frontend/admin/src/modules/settings/index.tsx
2026-02-17 00:56:03 +08:00

257 lines
7.9 KiB
TypeScript

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 { useCallback, useState } from 'react';
import { Header } from '../header';
import {
ALL_CONFIG_DESCRIPTORS,
ALL_SETTING_GROUPS,
type AppConfig,
} from './config';
import { type ConfigInputProps, ConfigRow } from './config-input-row';
import { useAppConfig } from './use-app-config';
export function SettingsPage() {
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" />
<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 [groupErrors, setGroupErrors] = useState<
Record<string, Record<string, string>>
>({});
const onFieldErrorChange = useCallback((field: string, error?: string) => {
const [module] = field.split('/');
if (!module) {
return;
}
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 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
);
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>
<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>
);
})}
</Accordion>
</div>
</ScrollArea>
);
};