Skip to content

Commit 5b5dda8

Browse files
committed
feat(FR-1580): Implement custom theme preview mode feature (#4653)
### Add Theme Preview Mode and Export Functionality Adds functionality for users to directly change the theme of the WebUI. Since the API for saving settings is not yet supported, currently only extraction to a JSON-formatted file is possible. Theme settings support a preview window; if the preview page is open, changing values in the origin window automatically applies them to the preview window. This PR enhances the theme customization experience by adding: 1. A theme preview mode that allows users to see their theme changes in a new window without affecting their current session 2. The ability to export custom theme configurations as JSON files 3. Warning alerts to inform users about theme preview mode and application process The implementation includes: - A new "Preview" button that opens a new window with the custom theme applied - An "Export JSON" button to download the current theme configuration - A warning alert explaining that theme changes require support team assistance to apply - A notification banner that appears when in preview mode - Session storage management to track preview mode state - Event listeners to automatically refresh preview windows when theme settings change [Screen Recording 2025-11-17 at 5.07.41 PM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/610104d6-130d-4392-a7f1-f3c67d1cdc1b.mov" />](https://app.graphite.com/user-attachments/video/610104d6-130d-4392-a7f1-f3c67d1cdc1b.mov) **Checklist:** - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after
1 parent eacf860 commit 5b5dda8

27 files changed

+341
-35
lines changed

react/src/components/BrandingSettingList.tsx

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
1+
import BAIAlert from './BAIAlert';
12
import ThemeColorPicker from './BrandingSettingItems/ThemeColorPicker';
23
import SettingList, { SettingGroup } from './SettingList';
3-
import { BAIButton } from 'backend.ai-ui';
4+
import { ExportOutlined } from '@ant-design/icons';
5+
import { App } from 'antd';
6+
import { BAIButton, BAIFlex } from 'backend.ai-ui';
7+
import _ from 'lodash';
48
import { useTranslation } from 'react-i18next';
9+
import { downloadBlob } from 'src/helper/csv-util';
510
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
611

712
interface BrandingSettingListProps {}
813

914
const BrandingSettingList: React.FC<BrandingSettingListProps> = () => {
1015
const { t } = useTranslation();
16+
const { message } = App.useApp();
1117

12-
const [, setUserCustomThemeConfig] = useBAISettingUserState(
13-
'custom_theme_config',
14-
);
18+
const [userCustomThemeConfig, setUserCustomThemeConfig] =
19+
useBAISettingUserState('custom_theme_config');
1520

1621
const settingGroups: Array<SettingGroup> = [
1722
{
1823
'data-testid': 'group-theme-customization',
1924
title: t('userSettings.Theme'),
2025
titleExtra: (
21-
<BAIButton size="small">{t('userSettings.theme.Preview')}</BAIButton>
26+
<BAIButton
27+
size="small"
28+
action={async () => {
29+
const previewWindow = window.open(window.location.origin, '_blank');
30+
previewWindow?.sessionStorage.setItem('isThemePreviewMode', 'true');
31+
previewWindow?.location.reload();
32+
}}
33+
>
34+
{t('userSettings.theme.Preview')}
35+
</BAIButton>
2236
),
2337
settingItems: [
2438
{
@@ -82,7 +96,40 @@ const BrandingSettingList: React.FC<BrandingSettingListProps> = () => {
8296
},
8397
];
8498

85-
return <SettingList settingGroups={settingGroups} showSearchBar />;
99+
return (
100+
<BAIFlex direction="column" gap="md" align="stretch">
101+
<BAIAlert
102+
description={t('userSettings.theme.CustomThemeSettingAlert')}
103+
type="warning"
104+
showIcon
105+
/>
106+
<SettingList
107+
showSearchBar
108+
showResetButton
109+
settingGroups={settingGroups}
110+
primaryButton={
111+
<BAIButton
112+
type="primary"
113+
icon={<ExportOutlined />}
114+
action={async () => {
115+
if (_.isEmpty(userCustomThemeConfig)) {
116+
message.error(t('userSettings.theme.NoChangesMade'));
117+
return;
118+
}
119+
120+
const blob = new Blob(
121+
[JSON.stringify(userCustomThemeConfig, null, 2)],
122+
{ type: 'application/json' },
123+
);
124+
downloadBlob(blob, `theme.json`);
125+
}}
126+
>
127+
{t('theme.button.ExportToJson')}
128+
</BAIButton>
129+
}
130+
/>
131+
</BAIFlex>
132+
);
86133
};
87134

88135
export default BrandingSettingList;

react/src/components/DefaultProviders.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ThemeModeProvider, useThemeMode } from '../hooks/useThemeMode';
1313
import indexCss from '../index.css?raw';
1414
import { StyleProvider, createCache } from '@ant-design/cssinjs';
1515
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
16-
import { useUpdateEffect } from 'ahooks';
16+
import { useSessionStorageState, useUpdateEffect } from 'ahooks';
1717
import { App, AppProps, theme, Typography } from 'antd';
1818
import { BAIConfigProvider } from 'backend.ai-ui';
1919
import dayjs from 'dayjs';
@@ -47,17 +47,20 @@ import weekday from 'dayjs/plugin/weekday';
4747
import i18n from 'i18next';
4848
import Backend from 'i18next-http-backend';
4949
import { createStore, Provider as JotaiProvider } from 'jotai';
50+
import _ from 'lodash';
5051
import { GlobeIcon } from 'lucide-react';
5152
import React, {
5253
Suspense,
5354
useEffect,
55+
useEffectEvent,
5456
useLayoutEffect,
5557
useMemo,
5658
useState,
5759
} from 'react';
5860
import { useTranslation, initReactI18next } from 'react-i18next';
5961
import { RelayEnvironmentProvider } from 'react-relay/hooks';
6062
import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom';
63+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
6164
import { QueryParamProvider } from 'use-query-params';
6265
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
6366

@@ -205,7 +208,34 @@ const DefaultProvidersForWebComponent: React.FC<DefaultProvidersProps> = ({
205208
}) => {
206209
const cache = useMemo(() => createCache(), []);
207210
const [lang] = useCurrentLanguage();
211+
212+
const [userCustomThemeConfig] = useBAISettingUserState('custom_theme_config');
213+
const [isThemePreviewMode] = useSessionStorageState('isThemePreviewMode', {
214+
defaultValue: false,
215+
});
208216
const themeConfig = useCustomThemeConfig();
217+
const defaultThemeConfig =
218+
isThemePreviewMode && !_.isEmpty(userCustomThemeConfig)
219+
? userCustomThemeConfig
220+
: themeConfig;
221+
222+
const reloadPreviewWindow = useEffectEvent(() => {
223+
if (!isThemePreviewMode) return;
224+
225+
const handleLocalStorageChange = (e: StorageEvent) => {
226+
if (e.key === 'backendaiwebui.settings.user.custom_theme_config') {
227+
window.location.reload();
228+
}
229+
};
230+
window.addEventListener('storage', handleLocalStorageChange);
231+
return () =>
232+
window.removeEventListener('storage', handleLocalStorageChange);
233+
});
234+
235+
useEffect(() => {
236+
reloadPreviewWindow();
237+
}, []);
238+
209239
const { isDarkMode } = useThemeMode();
210240

211241
const componentValues = useMemo(() => {
@@ -249,8 +279,8 @@ const DefaultProvidersForWebComponent: React.FC<DefaultProvidersProps> = ({
249279
}}
250280
theme={{
251281
...(isDarkMode
252-
? { ...themeConfig?.dark }
253-
: { ...themeConfig?.light }),
282+
? { ...defaultThemeConfig?.dark }
283+
: { ...defaultThemeConfig?.light }),
254284
algorithm: isDarkMode
255285
? theme.darkAlgorithm
256286
: theme.defaultAlgorithm,
@@ -325,9 +355,35 @@ export const DefaultProvidersForReactRoot: React.FC<
325355
Partial<DefaultProvidersProps>
326356
> = ({ children }) => {
327357
const [lang] = useCurrentLanguage();
328-
const themeConfig = useCustomThemeConfig();
329358
const { isDarkMode } = useThemeMode();
330359

360+
const [userCustomThemeConfig] = useBAISettingUserState('custom_theme_config');
361+
const [isThemePreviewMode] = useSessionStorageState('isThemePreviewMode', {
362+
defaultValue: false,
363+
});
364+
const themeConfig = useCustomThemeConfig();
365+
const defaultThemeConfig =
366+
isThemePreviewMode && !_.isEmpty(userCustomThemeConfig)
367+
? userCustomThemeConfig
368+
: themeConfig;
369+
370+
const reloadPreviewWindow = useEffectEvent(() => {
371+
if (!isThemePreviewMode) return;
372+
373+
const handleLocalStorageChange = (e: StorageEvent) => {
374+
if (e.key === 'backendaiwebui.settings.user.custom_theme_config') {
375+
window.location.reload();
376+
}
377+
};
378+
window.addEventListener('storage', handleLocalStorageChange);
379+
return () =>
380+
window.removeEventListener('storage', handleLocalStorageChange);
381+
});
382+
383+
useEffect(() => {
384+
reloadPreviewWindow();
385+
}, []);
386+
331387
return (
332388
<>
333389
<style>{indexCss}</style>
@@ -341,8 +397,8 @@ export const DefaultProvidersForReactRoot: React.FC<
341397
}
342398
theme={{
343399
...(isDarkMode
344-
? { ...themeConfig?.dark }
345-
: { ...themeConfig?.light }),
400+
? { ...defaultThemeConfig?.dark }
401+
: { ...defaultThemeConfig?.light }),
346402
algorithm: isDarkMode
347403
? theme.darkAlgorithm
348404
: theme.defaultAlgorithm,

react/src/components/MainLayout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ForceTOTPChecker from '../ForceTOTPChecker';
1010
import NetworkStatusBanner from '../NetworkStatusBanner';
1111
import NoResourceGroupAlert from '../NoResourceGroupAlert';
1212
import PasswordChangeRequestAlert from '../PasswordChangeRequestAlert';
13+
import ThemePreviewModeAlert from '../ThemePreviewModeAlert';
1314
import { DRAWER_WIDTH } from '../WEBUINotificationDrawer';
1415
import WebUIBreadcrumb from '../WebUIBreadcrumb';
1516
import WebUIHeader from './WebUIHeader';
@@ -235,6 +236,7 @@ function MainLayout() {
235236
align="stretch"
236237
className={styles.alertWrapper}
237238
>
239+
<ThemePreviewModeAlert />
238240
<ErrorBoundaryWithNullFallback>
239241
<NoResourceGroupAlert />
240242
</ErrorBoundaryWithNullFallback>

react/src/components/SettingList.tsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { RedoOutlined, SearchOutlined } from '@ant-design/icons';
33
import { useToggle } from 'ahooks';
44
import {
55
Alert,
6-
Button,
76
Checkbox,
87
Divider,
98
Empty,
@@ -13,9 +12,9 @@ import {
1312
theme,
1413
} from 'antd';
1514
import { createStyles } from 'antd-style';
16-
import { BAIModal, BAIFlex } from 'backend.ai-ui';
15+
import { BAIModal, BAIFlex, BAIButton } from 'backend.ai-ui';
1716
import _ from 'lodash';
18-
import { useState, ReactNode } from 'react';
17+
import React, { useState, ReactNode } from 'react';
1918
import { useTranslation } from 'react-i18next';
2019

2120
const useStyles = createStyles(({ css }) => ({
@@ -35,6 +34,7 @@ export type SettingGroup = {
3534
titleExtra?: ReactNode;
3635
description?: ReactNode;
3736
settingItems: SettingItemProps[];
37+
alert?: ReactNode;
3838
};
3939

4040
interface SettingPageProps {
@@ -43,6 +43,8 @@ interface SettingPageProps {
4343
showChangedOptionFilter?: boolean;
4444
showResetButton?: boolean;
4545
showSearchBar?: boolean;
46+
primaryButton?: ReactNode;
47+
extraButton?: ReactNode;
4648
}
4749

4850
const TabTitle: React.FC<{
@@ -108,6 +110,7 @@ const GroupSettingItems: React.FC<
108110
)}
109111
</BAIFlex>
110112
<BAIFlex direction="column" align="stretch" gap={'lg'}>
113+
{group.alert}
111114
{group.settingItems.map((item, idx) => (
112115
<SettingItem key={item.title + idx} {...item} />
113116
))}
@@ -122,6 +125,8 @@ const SettingList: React.FC<SettingPageProps> = ({
122125
showChangedOptionFilter,
123126
showResetButton,
124127
showSearchBar,
128+
primaryButton,
129+
extraButton,
125130
}) => {
126131
'use memo';
127132

@@ -180,14 +185,16 @@ const SettingList: React.FC<SettingPageProps> = ({
180185
{t('settings.ShowOnlyChanged')}
181186
</Checkbox>
182187
)}
188+
{extraButton}
183189
{!!showResetButton && (
184-
<Button
190+
<BAIButton
185191
icon={<RedoOutlined />}
186192
onClick={() => setIsOpenResetChangesModal()}
187193
>
188194
{t('button.Reset')}
189-
</Button>
195+
</BAIButton>
190196
)}
197+
{primaryButton}
191198
</BAIFlex>
192199
<Tabs
193200
activeKey={activeTabKey}
@@ -241,21 +248,24 @@ const SettingList: React.FC<SettingPageProps> = ({
241248
count={group.settingItems.length}
242249
/>
243250
),
244-
children:
245-
group.settingItems.length > 0 ? (
246-
<GroupSettingItems
247-
group={group}
248-
hideEmpty
249-
onReset={() => {
250-
setIsOpenResetChangesModal();
251-
}}
252-
/>
253-
) : (
254-
<Empty
255-
image={Empty.PRESENTED_IMAGE_SIMPLE}
256-
description={t('settings.NoChangesToDisplay')}
257-
/>
258-
),
251+
children: (
252+
<BAIFlex direction="column" align="stretch" gap={'xl'}>
253+
{group.settingItems.length > 0 ? (
254+
<GroupSettingItems
255+
group={group}
256+
hideEmpty
257+
onReset={() => {
258+
setIsOpenResetChangesModal();
259+
}}
260+
/>
261+
) : (
262+
<Empty
263+
image={Empty.PRESENTED_IMAGE_SIMPLE}
264+
description={t('settings.NoChangesToDisplay')}
265+
/>
266+
)}
267+
</BAIFlex>
268+
),
259269
})),
260270
]}
261271
/>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import BAIAlert, { BAIAlertProps } from './BAIAlert';
2+
import { useSessionStorageState } from 'ahooks';
3+
import { useTranslation } from 'react-i18next';
4+
5+
interface ThemePreviewModeAlertProps extends BAIAlertProps {}
6+
7+
const ThemePreviewModeAlert: React.FC<ThemePreviewModeAlertProps> = () => {
8+
const { t } = useTranslation();
9+
const [isThemePreviewMode] = useSessionStorageState('isThemePreviewMode', {
10+
defaultValue: false,
11+
});
12+
13+
return isThemePreviewMode ? (
14+
<BAIAlert showIcon type="warning" message={t('theme.PreviewModeAlert')} />
15+
) : null;
16+
};
17+
18+
export default ThemePreviewModeAlert;

react/src/pages/ConfigurationsPage.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ConfigurationsSettingList from '../components/ConfigurationsSettingList';
2+
import { useSessionStorageState } from 'ahooks';
23
import { Card, Skeleton } from 'antd';
4+
import { filterOutEmpty } from 'backend.ai-ui';
35
import { Suspense } from 'react';
46
import { useTranslation } from 'react-i18next';
57
import BAIErrorBoundary from 'src/components/BAIErrorBoundary';
@@ -13,21 +15,24 @@ const tabParam = withDefault(StringParam, 'configurations');
1315
const ConfigurationsPage = () => {
1416
const { t } = useTranslation();
1517
const [curTabKey, setCurTabKey] = useQueryParam('tab', tabParam);
18+
const [isThemePreviewMode] = useSessionStorageState('isThemePreviewMode', {
19+
defaultValue: false,
20+
});
1621

1722
return (
1823
<Card
1924
activeTabKey={curTabKey}
2025
onTabChange={(key) => setCurTabKey(key as TabKey)}
21-
tabList={[
26+
tabList={filterOutEmpty([
2227
{
2328
key: 'configurations',
2429
tab: t('webui.menu.Configurations'),
2530
},
26-
{
31+
!isThemePreviewMode && {
2732
key: 'branding',
2833
tab: t('webui.menu.Branding'),
2934
},
30-
]}
35+
])}
3136
>
3237
<Suspense fallback={<Skeleton active />}>
3338
{curTabKey === 'configurations' && (

0 commit comments

Comments
 (0)