Skip to content

Commit 39ad54d

Browse files
committed
feat(FR-1573): Add theme setting section in user settings page
1 parent 71d1e09 commit 39ad54d

31 files changed

+703
-57
lines changed

react/src/components/AutoRefreshSwitch.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Switch, SwitchProps, Typography } from 'antd';
2-
import { BAIFlex } from 'backend.ai-ui';
2+
import { BAIFlex, useInterval } from 'backend.ai-ui';
33
import React from 'react';
4-
import { useInterval } from 'src/hooks/useIntervalValue';
54

65
const { Text } = Typography;
76

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Col, ColorPicker, ColorPickerProps, Row, theme } from 'antd';
2+
import { ComponentTokenMap } from 'antd/es/theme/interface';
3+
import { AliasToken } from 'antd/lib/theme/internal';
4+
import { BAIFlex } from 'backend.ai-ui';
5+
import _ from 'lodash';
6+
import { useTranslation } from 'react-i18next';
7+
import {
8+
CustomThemeConfig,
9+
useCustomThemeConfig,
10+
} from 'src/helper/customThemeConfig';
11+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
12+
13+
type TokenPath = `token.${keyof AliasToken & string}`;
14+
type ComponentPath = `components.${keyof ComponentTokenMap & string}.${string}`;
15+
type ThemeConfigPath = TokenPath | ComponentPath;
16+
17+
interface ThemeColorPickerSettingItemProps extends ColorPickerProps {
18+
tokenName?: ThemeConfigPath;
19+
afterChangeColor?: (config: CustomThemeConfig) => void;
20+
}
21+
const ThemeColorPicker: React.FC<ThemeColorPickerSettingItemProps> = ({
22+
tokenName,
23+
}) => {
24+
'use memo';
25+
26+
const { t } = useTranslation();
27+
const { token } = theme.useToken();
28+
const [userCustomThemeConfig, setUserCustomThemeConfig] =
29+
useBAISettingUserState('custom_theme_config');
30+
31+
const themeConfig = useCustomThemeConfig();
32+
33+
const lightModeColor = (
34+
_.get(userCustomThemeConfig, `light.${tokenName}`) ||
35+
_.get(themeConfig, `light.${tokenName}`)
36+
)?.toString();
37+
const darkModeColor = (
38+
_.get(userCustomThemeConfig, `dark.${tokenName}`) ||
39+
_.get(themeConfig, `dark.${tokenName}`)
40+
)?.toString();
41+
42+
return (
43+
<BAIFlex
44+
align="stretch"
45+
direction="column"
46+
style={{ alignSelf: 'stretch' }}
47+
>
48+
<Row gutter={16}>
49+
<Col span={6}>
50+
<BAIFlex gap="sm" style={{ color: token.colorTextTertiary }}>
51+
{t('userSettings.LightMode')}:
52+
<ColorPicker
53+
format="hex"
54+
showText
55+
value={lightModeColor}
56+
onChangeComplete={(value) => {
57+
const newColor = value.toHexString();
58+
const newCustomThemeConfig: CustomThemeConfig = _.cloneDeep({
59+
...themeConfig!,
60+
...userCustomThemeConfig,
61+
});
62+
_.set(newCustomThemeConfig, `light.${tokenName}`, newColor);
63+
setUserCustomThemeConfig(newCustomThemeConfig);
64+
}}
65+
/>
66+
</BAIFlex>
67+
</Col>
68+
<Col span={6}>
69+
<BAIFlex gap="sm" style={{ color: token.colorTextTertiary }}>
70+
{t('userSettings.DarkMode')}:
71+
<ColorPicker
72+
format="hex"
73+
showText
74+
value={darkModeColor}
75+
onChangeComplete={(value) => {
76+
const newColor = value.toHexString();
77+
const newCustomThemeConfig: CustomThemeConfig = _.cloneDeep({
78+
...themeConfig!,
79+
...userCustomThemeConfig,
80+
});
81+
_.set(newCustomThemeConfig, `dark.${tokenName}`, newColor);
82+
setUserCustomThemeConfig(newCustomThemeConfig);
83+
}}
84+
/>
85+
</BAIFlex>
86+
</Col>
87+
</Row>
88+
</BAIFlex>
89+
);
90+
};
91+
92+
export default ThemeColorPicker;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import ThemeColorPicker from './BrandingSettingItems/ThemeColorPicker';
2+
import SettingList, { SettingGroup } from './SettingList';
3+
import { BAIButton } from 'backend.ai-ui';
4+
import { useTranslation } from 'react-i18next';
5+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
6+
7+
interface BrandingSettingListProps {}
8+
9+
const BrandingSettingList: React.FC<BrandingSettingListProps> = () => {
10+
const { t } = useTranslation();
11+
12+
const [, setUserCustomThemeConfig] = useBAISettingUserState(
13+
'custom_theme_config',
14+
);
15+
16+
const settingGroups: Array<SettingGroup> = [
17+
{
18+
'data-testid': 'group-theme-customization',
19+
title: t('userSettings.Theme'),
20+
titleExtra: (
21+
<BAIButton size="small">{t('userSettings.theme.Preview')}</BAIButton>
22+
),
23+
settingItems: [
24+
{
25+
type: 'custom',
26+
title: t('userSettings.theme.PrimaryColor'),
27+
description: t('userSettings.theme.PrimaryColorDesc'),
28+
children: <ThemeColorPicker tokenName="token.colorPrimary" />,
29+
// for reset feature
30+
defaultValue: {},
31+
setValue: setUserCustomThemeConfig,
32+
},
33+
{
34+
type: 'custom',
35+
title: t('userSettings.theme.HeaderBg'),
36+
description: t('userSettings.theme.HeaderBgDesc'),
37+
children: <ThemeColorPicker tokenName="components.Layout.headerBg" />,
38+
defaultValue: {},
39+
setValue: setUserCustomThemeConfig,
40+
},
41+
{
42+
type: 'custom',
43+
title: t('userSettings.theme.LinkColor'),
44+
description: t('userSettings.theme.LinkColorDesc'),
45+
children: <ThemeColorPicker tokenName="token.colorLink" />,
46+
defaultValue: {},
47+
setValue: setUserCustomThemeConfig,
48+
},
49+
{
50+
type: 'custom',
51+
title: t('userSettings.theme.InfoColor'),
52+
description: t('userSettings.theme.InfoColorDesc'),
53+
children: <ThemeColorPicker tokenName="token.colorInfo" />,
54+
defaultValue: {},
55+
setValue: setUserCustomThemeConfig,
56+
},
57+
{
58+
type: 'custom',
59+
title: t('userSettings.theme.ErrorColor'),
60+
description: t('userSettings.theme.ErrorColorDesc'),
61+
children: <ThemeColorPicker tokenName="token.colorError" />,
62+
defaultValue: {},
63+
setValue: setUserCustomThemeConfig,
64+
},
65+
{
66+
type: 'custom',
67+
title: t('userSettings.theme.SuccessColor'),
68+
description: t('userSettings.theme.SuccessColorDesc'),
69+
children: <ThemeColorPicker tokenName="token.colorSuccess" />,
70+
defaultValue: {},
71+
setValue: setUserCustomThemeConfig,
72+
},
73+
{
74+
type: 'custom',
75+
title: t('userSettings.theme.TextColor'),
76+
description: t('userSettings.theme.TextColorDesc'),
77+
children: <ThemeColorPicker tokenName="token.colorText" />,
78+
defaultValue: {},
79+
setValue: setUserCustomThemeConfig,
80+
},
81+
],
82+
},
83+
];
84+
85+
return <SettingList settingGroups={settingGroups} showSearchBar />;
86+
};
87+
88+
export default BrandingSettingList;

react/src/components/SettingItem.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,15 @@ const SettingItem: React.FC<SettingItemProps> = ({
6464
defaultValue !== value && <Badge dot status="warning" />}
6565
</BAIFlex>
6666
{type === 'custom' && (
67-
<>
67+
<BAIFlex
68+
direction="column"
69+
gap="xs"
70+
align="start"
71+
style={{ width: '100%' }}
72+
>
6873
{description}
69-
<div style={{ marginTop: token.marginXS }}>{children}</div>
70-
</>
74+
{children}
75+
</BAIFlex>
7176
)}
7277
{type === 'checkbox' && (
7378
<Checkbox

react/src/components/SettingList.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const useStyles = createStyles(({ css }) => ({
3232
export type SettingGroup = {
3333
'data-testid': string;
3434
title: string;
35+
titleExtra?: ReactNode;
3536
description?: ReactNode;
3637
settingItems: SettingItemProps[];
3738
};
@@ -61,8 +62,9 @@ const GroupSettingItems: React.FC<
6162
group: SettingGroup;
6263
hideEmpty?: boolean;
6364
} & React.HTMLAttributes<HTMLDivElement>
64-
> = ({ group, hideEmpty, ...props }) => {
65+
> = ({ group, hideEmpty = true, ...props }) => {
6566
const { token } = theme.useToken();
67+
6668
if (hideEmpty && group.settingItems.length === 0) return false;
6769
return (
6870
<BAIFlex
@@ -82,14 +84,19 @@ const GroupSettingItems: React.FC<
8284
background: token.colorBgContainer,
8385
}}
8486
>
85-
<Typography.Title
86-
level={5}
87-
style={{
88-
marginTop: 0,
89-
}}
90-
>
91-
{group.title}
92-
</Typography.Title>
87+
<BAIFlex align="start" justify="between">
88+
<BAIFlex gap="sm" align="start">
89+
<Typography.Title
90+
level={5}
91+
style={{
92+
marginTop: 0,
93+
}}
94+
>
95+
{group.title}
96+
</Typography.Title>
97+
{group.titleExtra && <div>{group.titleExtra}</div>}
98+
</BAIFlex>
99+
</BAIFlex>
93100
<Divider style={{ marginTop: 0, marginBottom: 0 }} />
94101
{group.description && (
95102
<Typography.Text
@@ -100,7 +107,7 @@ const GroupSettingItems: React.FC<
100107
</Typography.Text>
101108
)}
102109
</BAIFlex>
103-
<BAIFlex direction="column" align="start" gap={'lg'}>
110+
<BAIFlex direction="column" align="stretch" gap={'lg'}>
104111
{group.settingItems.map((item, idx) => (
105112
<SettingItem key={item.title + idx} {...item} />
106113
))}
@@ -116,6 +123,8 @@ const SettingList: React.FC<SettingPageProps> = ({
116123
showResetButton,
117124
showSearchBar,
118125
}) => {
126+
'use memo';
127+
119128
const { t } = useTranslation();
120129
const { styles } = useStyles();
121130
const [searchValue, setSearchValue] = useState('');
@@ -210,6 +219,9 @@ const SettingList: React.FC<SettingPageProps> = ({
210219
key={group.title}
211220
group={group}
212221
hideEmpty
222+
onReset={() => {
223+
setIsOpenResetChangesModal();
224+
}}
213225
/>
214226
))
215227
) : (
@@ -231,7 +243,13 @@ const SettingList: React.FC<SettingPageProps> = ({
231243
),
232244
children:
233245
group.settingItems.length > 0 ? (
234-
<GroupSettingItems group={group} hideEmpty />
246+
<GroupSettingItems
247+
group={group}
248+
hideEmpty
249+
onReset={() => {
250+
setIsOpenResetChangesModal();
251+
}}
252+
/>
235253
) : (
236254
<Empty
237255
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -248,13 +266,7 @@ const SettingList: React.FC<SettingPageProps> = ({
248266
okText={t('button.Reset')}
249267
okButtonProps={{ danger: true }}
250268
onOk={() => {
251-
_.flatMap(settingGroups, (item) => item.settingItems).forEach(
252-
(option) => {
253-
!option.disabled &&
254-
option?.setValue &&
255-
option.setValue(option.defaultValue);
256-
},
257-
);
269+
resetSettingItems(settingGroups);
258270
setIsOpenResetChangesModal();
259271
}}
260272
cancelText={t('button.Cancel')}
@@ -271,3 +283,11 @@ const SettingList: React.FC<SettingPageProps> = ({
271283
};
272284

273285
export default SettingList;
286+
287+
const resetSettingItems = (settingGroups: SettingGroup[]) => {
288+
_.flatMap(settingGroups, (item) => item.settingItems).forEach((option) => {
289+
!option.disabled &&
290+
option?.setValue &&
291+
option.setValue(option.defaultValue);
292+
});
293+
};

react/src/helper/customThemeConfig.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ThemeConfig } from 'antd';
22
import _ from 'lodash';
3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useEffectEvent, useState } from 'react';
44

55
type LogoConfig = {
66
src: string;
@@ -29,6 +29,13 @@ type BrandingConfig = {
2929
companyName?: string;
3030
brandName?: string;
3131
};
32+
export type CustomThemeConfig = {
33+
light: ThemeConfig;
34+
dark: ThemeConfig;
35+
logo: LogoConfig;
36+
sider?: SiderConfig;
37+
branding?: BrandingConfig;
38+
};
3239
let _customTheme:
3340
| {
3441
light: ThemeConfig;
@@ -70,8 +77,10 @@ export const loadCustomThemeConfig = () => {
7077
};
7178

7279
export const useCustomThemeConfig = () => {
73-
const [customThemeConfig, setCustomThemeConfig] = useState(_customTheme);
74-
useEffect(() => {
80+
const [customThemeConfig, setCustomThemeConfig] = useState<
81+
CustomThemeConfig | undefined
82+
>(_customTheme);
83+
const addEventListener = useEffectEvent(() => {
7584
if (!customThemeConfig) {
7685
const handler = () => {
7786
setCustomThemeConfig(_customTheme);
@@ -82,7 +91,10 @@ export const useCustomThemeConfig = () => {
8291
document.removeEventListener('custom-theme-loaded', handler);
8392
};
8493
}
85-
// eslint-disable-next-line react-hooks/exhaustive-deps
94+
});
95+
96+
useEffect(() => {
97+
addEventListener();
8698
}, []);
8799

88100
return customThemeConfig;

react/src/hooks/useBAISetting.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { jotaiStore } from '../components/DefaultProviders';
33
import { BAITableColumnOverrideRecord } from 'backend.ai-ui';
44
import { atom, useAtom } from 'jotai';
55
import { atomFamily } from 'jotai/utils';
6+
import { CustomThemeConfig } from 'src/helper/customThemeConfig';
67

78
interface UserSettings {
89
has_opened_tour_neo_session_validation?: boolean;
@@ -37,6 +38,7 @@ interface UserSettings {
3738
max_concurrent_uploads?: number;
3839
container_log_auto_refresh_enabled?: boolean;
3940
container_log_auto_refresh_interval?: number;
41+
custom_theme_config?: CustomThemeConfig;
4042
}
4143

4244
export type SessionHistory = {

0 commit comments

Comments
 (0)