Skip to content

Commit d1a9094

Browse files
committed
feat(FR-1573): Add theme setting section in user settings page
1 parent d409b1b commit d1a9094

29 files changed

+699
-45
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Col, ColorPicker, ColorPickerProps, Row, theme } from 'antd';
2+
import { ComponentTokenMap } from 'antd/es/theme/interface';
3+
import { ThemeConfig } from 'antd/lib';
4+
import { AliasToken } from 'antd/lib/theme/internal';
5+
import { BAIFlex } from 'backend.ai-ui';
6+
import _ from 'lodash';
7+
import { useTranslation } from 'react-i18next';
8+
import {
9+
CustomThemeConfig,
10+
useCustomThemeConfig,
11+
} from 'src/helper/customThemeConfig';
12+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
13+
14+
type TokenPath = `token.${keyof AliasToken & string}`;
15+
type ComponentPath = `components.${keyof ComponentTokenMap & string}.${string}`;
16+
type ThemeConfigPath = TokenPath | ComponentPath;
17+
18+
interface ThemeColorPickerSettingItemProps extends ColorPickerProps {
19+
tokenName?: ThemeConfigPath;
20+
afterChangeColor?: (config: CustomThemeConfig) => void;
21+
}
22+
const ThemeColorPicker: React.FC<ThemeColorPickerSettingItemProps> = ({
23+
tokenName,
24+
}) => {
25+
'use memo';
26+
27+
const { t } = useTranslation();
28+
const { token } = theme.useToken();
29+
const [userCustomThemeConfig, setUserCustomThemeConfig] =
30+
useBAISettingUserState('custom_theme_config');
31+
32+
const themeConfig = useCustomThemeConfig();
33+
34+
const lightModeColor = (
35+
_.get(userCustomThemeConfig, `light.${tokenName}`) ||
36+
_.get(themeConfig, `light.${tokenName}`)
37+
)?.toString();
38+
const darkModeColor = (
39+
_.get(userCustomThemeConfig, `dark.${tokenName}`) ||
40+
_.get(themeConfig, `dark.${tokenName}`)
41+
)?.toString();
42+
43+
return (
44+
<Row>
45+
<Col span={12}>
46+
<BAIFlex gap="sm" style={{ color: token.colorTextTertiary }}>
47+
{t('userSettings.LightMode')}:
48+
<ColorPicker
49+
format="hex"
50+
showText
51+
value={lightModeColor}
52+
onChangeComplete={(value) => {
53+
const newColor = value.toHexString();
54+
const newCustomThemeConfig: CustomThemeConfig = {
55+
...userCustomThemeConfig,
56+
...themeConfig!,
57+
light: {
58+
...(_.get(userCustomThemeConfig, 'light') ||
59+
(_.get(themeConfig, 'light') as ThemeConfig)),
60+
},
61+
};
62+
_.set(newCustomThemeConfig, `light.${tokenName}`, newColor);
63+
setUserCustomThemeConfig(newCustomThemeConfig);
64+
}}
65+
/>
66+
</BAIFlex>
67+
</Col>
68+
<Col span={12}>
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 = {
78+
...userCustomThemeConfig,
79+
...themeConfig!,
80+
dark: {
81+
...(_.get(userCustomThemeConfig, 'dark') ||
82+
(_.get(themeConfig, 'dark') as ThemeConfig)),
83+
},
84+
};
85+
_.set(newCustomThemeConfig, `dark.${tokenName}`, newColor);
86+
setUserCustomThemeConfig(newCustomThemeConfig);
87+
}}
88+
/>
89+
</BAIFlex>
90+
</Col>
91+
</Row>
92+
);
93+
};
94+
95+
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
4646
<BAIFlex
4747
data-testid={dataTestId}
4848
direction="column"
49-
align="start"
49+
align="stretch"
5050
gap={'xxs'}
5151
>
5252
<BAIFlex direction="row" gap={'xxs'}>
@@ -91,6 +91,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
9191
disabled={disabled}
9292
style={{
9393
marginTop: token.marginXS,
94+
maxWidth: 300,
9495
...selectProps?.style,
9596
}}
9697
{..._.omit(selectProps, ['style'])}

react/src/components/SettingList.tsx

Lines changed: 57 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
};
@@ -60,9 +61,15 @@ const GroupSettingItems: React.FC<
6061
{
6162
group: SettingGroup;
6263
hideEmpty?: boolean;
64+
showResetButton?: boolean;
65+
onReset?: () => void;
6366
} & React.HTMLAttributes<HTMLDivElement>
64-
> = ({ group, hideEmpty, ...props }) => {
67+
> = ({ group, hideEmpty, showResetButton = true, onReset, ...props }) => {
68+
const { t } = useTranslation();
6569
const { token } = theme.useToken();
70+
71+
const [showGroupResetButton, setShowGroupResetButton] = useState(false);
72+
6673
if (hideEmpty && group.settingItems.length === 0) return false;
6774
return (
6875
<BAIFlex
@@ -71,6 +78,8 @@ const GroupSettingItems: React.FC<
7178
style={{
7279
marginBottom: token.marginMD,
7380
}}
81+
onMouseEnter={() => setShowGroupResetButton(true)}
82+
onMouseLeave={() => setShowGroupResetButton(false)}
7483
{...props}
7584
>
7685
<BAIFlex
@@ -82,14 +91,24 @@ const GroupSettingItems: React.FC<
8291
background: token.colorBgContainer,
8392
}}
8493
>
85-
<Typography.Title
86-
level={5}
87-
style={{
88-
marginTop: 0,
89-
}}
90-
>
91-
{group.title}
92-
</Typography.Title>
94+
<BAIFlex align="start" justify="between">
95+
<BAIFlex gap="sm" align="start">
96+
<Typography.Title
97+
level={5}
98+
style={{
99+
marginTop: 0,
100+
}}
101+
>
102+
{group.title}
103+
</Typography.Title>
104+
{group.titleExtra && <div>{group.titleExtra}</div>}
105+
</BAIFlex>
106+
{!!showResetButton && showGroupResetButton && (
107+
<Button icon={<RedoOutlined />} size="small" onClick={onReset}>
108+
{t('button.Reset')}
109+
</Button>
110+
)}
111+
</BAIFlex>
93112
<Divider style={{ marginTop: 0, marginBottom: 0 }} />
94113
{group.description && (
95114
<Typography.Text
@@ -100,7 +119,7 @@ const GroupSettingItems: React.FC<
100119
</Typography.Text>
101120
)}
102121
</BAIFlex>
103-
<BAIFlex direction="column" align="start" gap={'lg'}>
122+
<BAIFlex direction="column" align="stretch" gap={'lg'}>
104123
{group.settingItems.map((item, idx) => (
105124
<SettingItem key={item.title + idx} {...item} />
106125
))}
@@ -116,10 +135,14 @@ const SettingList: React.FC<SettingPageProps> = ({
116135
showResetButton,
117136
showSearchBar,
118137
}) => {
138+
'use memo';
139+
119140
const { t } = useTranslation();
120141
const { styles } = useStyles();
121142
const [searchValue, setSearchValue] = useState('');
122143
const [changedOptionFilter, setChangedOptionFilter] = useState(false);
144+
const [selectedGroupToReset, setSelectedGroupToReset] =
145+
useState<SettingGroup | null>(null);
123146
const [isOpenResetChangesModal, { toggle: setIsOpenResetChangesModal }] =
124147
useToggle(false);
125148
const [activeTabKey, setActiveTabKey] = useState('all');
@@ -210,6 +233,10 @@ const SettingList: React.FC<SettingPageProps> = ({
210233
key={group.title}
211234
group={group}
212235
hideEmpty
236+
onReset={() => {
237+
setSelectedGroupToReset(group);
238+
setIsOpenResetChangesModal();
239+
}}
213240
/>
214241
))
215242
) : (
@@ -231,7 +258,14 @@ const SettingList: React.FC<SettingPageProps> = ({
231258
),
232259
children:
233260
group.settingItems.length > 0 ? (
234-
<GroupSettingItems group={group} hideEmpty />
261+
<GroupSettingItems
262+
group={group}
263+
hideEmpty
264+
onReset={() => {
265+
setSelectedGroupToReset(group);
266+
setIsOpenResetChangesModal();
267+
}}
268+
/>
235269
) : (
236270
<Empty
237271
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -248,14 +282,11 @@ const SettingList: React.FC<SettingPageProps> = ({
248282
okText={t('button.Reset')}
249283
okButtonProps={{ danger: true }}
250284
onOk={() => {
251-
_.flatMap(settingGroups, (item) => item.settingItems).forEach(
252-
(option) => {
253-
!option.disabled &&
254-
option?.setValue &&
255-
option.setValue(option.defaultValue);
256-
},
257-
);
285+
selectedGroupToReset
286+
? resetSettingItems([selectedGroupToReset])
287+
: resetSettingItems(settingGroups);
258288
setIsOpenResetChangesModal();
289+
setSelectedGroupToReset(null);
259290
}}
260291
cancelText={t('button.Cancel')}
261292
onCancel={() => setIsOpenResetChangesModal()}
@@ -271,3 +302,11 @@ const SettingList: React.FC<SettingPageProps> = ({
271302
};
272303

273304
export default SettingList;
305+
306+
const resetSettingItems = (settingGroups: SettingGroup[]) => {
307+
_.flatMap(settingGroups, (item) => item.settingItems).forEach((option) => {
308+
!option.disabled &&
309+
option?.setValue &&
310+
option.setValue(option.defaultValue);
311+
});
312+
};

react/src/helper/customThemeConfig.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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,7 +77,9 @@ export const loadCustomThemeConfig = () => {
7077
};
7178

7279
export const useCustomThemeConfig = () => {
73-
const [customThemeConfig, setCustomThemeConfig] = useState(_customTheme);
80+
const [customThemeConfig, setCustomThemeConfig] = useState<
81+
CustomThemeConfig | undefined
82+
>(_customTheme);
7483
useEffect(() => {
7584
if (!customThemeConfig) {
7685
const handler = () => {

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;
@@ -35,6 +36,7 @@ interface UserSettings {
3536

3637
classic_session_list?: boolean; // `experimental_neo_session_list` has been replaced with `classic_session_list`
3738
max_concurrent_uploads?: number;
39+
custom_theme_config?: CustomThemeConfig;
3840
}
3941

4042
export type SessionHistory = {

0 commit comments

Comments
 (0)