Merge pull request #876 from spotify/rugvip/theme

package/core: make themes configurable as a part of app creation, and add API for switching themes
This commit is contained in:
Patrik Oldsberg
2020-05-15 19:08:34 +02:00
committed by GitHub
16 changed files with 574 additions and 177 deletions
+10 -48
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { CssBaseline, ThemeProvider } from '@material-ui/core';
import { lightTheme, darkTheme } from '@backstage/theme';
import { createApp } from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
@@ -23,7 +21,6 @@ import Root from './components/Root';
import AlertDisplay from './components/AlertDisplay';
import * as plugins from './plugins';
import apis, { alertApiForwarder } from './apis';
import { ThemeContextType, ThemeContext, useThemeType } from './ThemeContext';
const app = createApp({
apis,
@@ -33,50 +30,15 @@ const app = createApp({
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
const App: FC<{}> = () => {
const [theme, toggleTheme] = useThemeType(
localStorage.getItem('theme') || 'auto',
);
let backstageTheme = lightTheme;
switch (theme) {
case 'light':
backstageTheme = lightTheme;
break;
case 'dark':
backstageTheme = darkTheme;
break;
default:
if (!window.matchMedia) {
backstageTheme = lightTheme;
break;
}
backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? darkTheme
: lightTheme;
break;
}
const themeContext: ThemeContextType = {
theme,
toggleTheme,
};
return (
<AppProvider>
<ThemeContext.Provider value={themeContext}>
<ThemeProvider theme={backstageTheme}>
<CssBaseline>
<AlertDisplay forwarder={alertApiForwarder} />
<Router>
<Root>
<AppComponent />
</Root>
</Router>
</CssBaseline>
</ThemeProvider>
</ThemeContext.Provider>
</AppProvider>
);
};
const App: FC<{}> = () => (
<AppProvider>
<AlertDisplay forwarder={alertApiForwarder} />
<Router>
<Root>
<AppComponent />
</Root>
</Router>
</AppProvider>
);
export default App;
-69
View File
@@ -1,69 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
export type ThemeContextType = {
theme: string;
toggleTheme: () => void;
};
export const ThemeContext = React.createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {},
});
export function useThemeType(themeId: string): [string, () => void] {
const [theme, setTheme] = useState(themeId);
useEffect(() => {
if (!window.matchMedia) {
return () => {};
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const darkListener = (event: MediaQueryListEvent) => {
if (localStorage.getItem('theme') === 'auto') {
if (event.matches) {
setTheme('dark');
} else {
setTheme('light');
}
setTheme('auto');
}
};
mql.addListener(darkListener);
return () => {
mql.removeListener(darkListener);
};
});
function toggleTheme() {
if (theme === 'light') {
setTheme('dark');
localStorage.setItem('theme', 'dark');
} else if (theme === 'dark') {
if (!window.matchMedia) {
setTheme('light');
localStorage.setItem('theme', 'light');
setTheme('light');
return;
}
setTheme('auto');
localStorage.setItem('theme', 'auto');
setTheme('auto');
} else {
setTheme('light');
localStorage.setItem('theme', 'light');
}
}
return [theme, toggleTheme];
}
@@ -15,42 +15,47 @@
*/
import React, { FC } from 'react';
import { SidebarItem } from '@backstage/core';
import { ThemeContext } from '../../ThemeContext';
import { useObservable } from 'react-use';
import { SidebarItem, useApi, appThemeApiRef } from '@backstage/core';
import WbSunnyIcon from '@material-ui/icons/WbSunny';
import Brightness2Icon from '@material-ui/icons/Brightness2';
import ToggleOnIcon from '@material-ui/icons/ToggleOn';
const ToggleThemeSidebarItem: FC<{}> = () => {
return (
<ThemeContext.Consumer>
{themeContext => {
let text = 'Auto';
let icon = ToggleOnIcon;
switch (themeContext.theme) {
case 'dark':
text = 'Dark mode';
icon = Brightness2Icon;
break;
case 'light':
text = 'Light mode';
icon = WbSunnyIcon;
break;
default:
text = 'Auto';
icon = ToggleOnIcon;
break;
}
return (
<SidebarItem
text={text}
onClick={themeContext.toggleTheme}
icon={icon}
/>
);
}}
</ThemeContext.Consumer>
const ToggleThemeSidebarItem: FC<{}> = () => {
const appThemeApi = useApi(appThemeApiRef);
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
let text = 'Auto';
let icon = ToggleOnIcon;
switch (themeId) {
case 'dark':
text = 'Dark mode';
icon = Brightness2Icon;
break;
case 'light':
text = 'Light mode';
icon = WbSunnyIcon;
break;
default:
text = 'Auto';
icon = ToggleOnIcon;
break;
}
const handleToggle = () => {
if (!themeId) {
appThemeApi.setActiveThemeId('light');
} else if (themeId === 'light') {
appThemeApi.setActiveThemeId('dark');
} else {
appThemeApi.setActiveThemeId(undefined);
}
};
return <SidebarItem text={text} onClick={handleToggle} icon={icon} />;
};
export default ToggleThemeSidebarItem;
@@ -1,6 +1,5 @@
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
import { makeStyles } from '@material-ui/core';
import { createApp } from '@backstage/core';
import { lightTheme } from '@backstage/theme';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import * as plugins from './plugins';
@@ -34,13 +33,9 @@ const App: FC<{}> = () => {
useStyles();
return (
<AppProvider>
<ThemeProvider theme={lightTheme}>
<CssBaseline>
<Router>
<AppComponent />
</Router>
</CssBaseline>
</ThemeProvider>
<Router>
<AppComponent />
</Router>
</AppProvider>
);
};
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiAggregator } from './ApiAggregator';
import { ApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
describe('ApiAggregator', () => {
const apiARef = new ApiRef<number>({ id: 'a', description: '' });
const apiBRef = new ApiRef<number>({ id: 'b', description: '' });
it('should forward implementations', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 5],
[apiBRef, 10],
]),
);
expect(agg.get(apiARef)).toBe(5);
expect(agg.get(apiBRef)).toBe(10);
});
it('should return the first implementation', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 1],
[apiARef, 2],
]),
);
expect(agg.get(apiARef)).toBe(2);
expect(agg.get(apiBRef)).toBe(undefined);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef } from './ApiRef';
import { ApiHolder } from './types';
/**
* An ApiHolder that queries multiple other holders from for
* an Api implementation, returning the first one encountered..
*/
export class ApiAggregator implements ApiHolder {
private readonly holders: ApiHolder[];
constructor(...holders: ApiHolder[]) {
this.holders = holders;
}
get<T>(apiRef: ApiRef<T>): T | undefined {
for (const holder of this.holders) {
const api = holder.get(apiRef);
if (api) {
return api;
}
}
return undefined;
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef } from '../ApiRef';
import { BackstageTheme } from '@backstage/theme';
import { Observable } from '../../types';
/**
* Describes a theme provided by the app.
*/
export type AppTheme = {
/**
* ID used to remember theme selections.
*/
id: string;
/**
* Title of the theme
*/
title: string;
/**
* Theme variant
*/
variant: 'light' | 'dark';
/**
* The specialized MaterialUI theme instance.
*/
theme: BackstageTheme;
};
/**
* The AppThemeApi gives access to the current app theme, and allows switching
* to other options that have been registered as a part of the App.
*/
export type AppThemeApi = {
/**
* Get a list of available themes.
*/
getInstalledThemes(): AppTheme[];
/**
* Observe the currently selected theme. A value of undefined means no specific theme has been selected.
*/
activeThemeId$(): Observable<string | undefined>;
/**
* Get the current theme ID. Returns undefined if no specific theme is selected.
*/
getActiveThemeId(): string | undefined;
/**
* Set a specific theme to use in the app, overriding the default theme selection.
*
* Clear the selection by passing in undefined.
*/
setActiveThemeId(themeId?: string): void;
};
export const appThemeApiRef = new ApiRef<AppThemeApi>({
id: 'core.apptheme',
description: 'API Used to configure the app theme, and enumerate options',
});
@@ -21,6 +21,7 @@
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
@@ -0,0 +1,85 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppTheme } from '../../definitions';
import { AppThemeSelector } from './AppThemeSelector';
describe('AppThemeSelector', () => {
it('should should select new themes', async () => {
const selector = new AppThemeSelector([]);
expect(selector.getInstalledThemes()).toEqual([]);
const subFn = jest.fn();
selector.activeThemeId$().subscribe(subFn);
expect(selector.getActiveThemeId()).toBe(undefined);
await 'wait a tick';
expect(subFn).toHaveBeenLastCalledWith(undefined);
selector.setActiveThemeId('x');
expect(subFn).toHaveBeenLastCalledWith('x');
expect(selector.getActiveThemeId()).toBe('x');
selector.setActiveThemeId(undefined);
expect(subFn).toHaveBeenLastCalledWith(undefined);
expect(selector.getActiveThemeId()).toBe(undefined);
});
it('should return a new array of themes', () => {
const themes = new Array<AppTheme>();
const selector = new AppThemeSelector(themes);
expect(selector.getInstalledThemes()).toEqual(themes);
expect(selector.getInstalledThemes()).not.toBe(themes);
});
it('should store theme in local storage', async () => {
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
localStorage.setItem('theme', 'x');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
localStorage.removeItem('theme');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
const addListenerSpy = jest.spyOn(window, 'addEventListener');
const selector = AppThemeSelector.createWithStorage([]);
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith(
'storage',
expect.any(Function),
);
selector.setActiveThemeId('y');
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe('y');
selector.setActiveThemeId(undefined);
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe(null);
localStorage.setItem('theme', 'z');
expect(selector.getActiveThemeId()).toBe(undefined);
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
listener({ key: 'theme' } as StorageEvent);
expect(selector.getActiveThemeId()).toBe('z');
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Observable from 'zen-observable';
import { AppThemeApi, AppTheme } from '../../definitions';
const STORAGE_KEY = 'theme';
export class AppThemeSelector implements AppThemeApi {
static createWithStorage(themes: AppTheme[]) {
const selector = new AppThemeSelector(themes);
if (!window.localStorage) {
return selector;
}
const initialThemeId =
window.localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe((themeId) => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
});
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
}
});
return selector;
}
private readonly themes: AppTheme[];
private activeThemeId: string | undefined;
private readonly observable: Observable<string | undefined>;
private readonly subscribers = new Set<
ZenObservable.SubscriptionObserver<string | undefined>
>();
constructor(themes: AppTheme[]) {
this.themes = themes;
this.observable = new Observable((subscriber) => {
subscriber.next(this.activeThemeId);
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
}
getInstalledThemes(): AppTheme[] {
return this.themes.slice();
}
activeThemeId$(): Observable<string | undefined> {
return this.observable;
}
getActiveThemeId(): string | undefined {
return this.activeThemeId;
}
setActiveThemeId(themeId?: string): void {
this.activeThemeId = themeId;
this.subscribers.forEach((subscriber) => subscriber.next(themeId));
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './AppThemeSelector';
@@ -18,6 +18,7 @@
//
// Plugins should rely on these APIs for functionality as much as possible.
export * from './AppThemeSelector';
export * from './AlertApiForwarder';
export * from './ErrorApiForwarder';
export * from './OAuthRequestManager';
+40 -8
View File
@@ -22,6 +22,7 @@ import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
import ErrorPage from '../../layout/ErrorPage';
import { AppThemeProvider } from './AppThemeProvider';
import {
IconComponent,
@@ -29,14 +30,24 @@ import {
SystemIconKey,
defaultSystemIcons,
} from '../../icons';
import { ApiHolder, ApiProvider, ApiRegistry } from '../apis';
import {
ApiHolder,
ApiProvider,
ApiRegistry,
AppTheme,
AppThemeSelector,
appThemeApiRef,
} from '../apis';
import LoginPage from './LoginPage';
import { lightTheme, darkTheme } from '@backstage/theme';
import { ApiAggregator } from '../apis/ApiAggregator';
type FullAppOptions = {
apis: ApiHolder;
icons: SystemIcons;
plugins: BackstagePlugin[];
components: AppComponents;
themes: AppTheme[];
};
class AppImpl implements BackstageApp {
@@ -44,12 +55,14 @@ class AppImpl implements BackstageApp {
private readonly icons: SystemIcons;
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
constructor(options: FullAppOptions) {
this.apis = options.apis;
this.icons = options.icons;
this.plugins = options.plugins;
this.components = options.components;
this.themes = options.themes;
}
getApis(): ApiHolder {
@@ -129,23 +142,28 @@ class AppImpl implements BackstageApp {
<Route key="login" path="/login" component={LoginPage} exact />,
);
let rendered = (
const rendered = (
<Switch>
{routes}
<Route component={NotFoundErrorPage} />
</Switch>
);
if (this.apis) {
rendered = <ApiProvider apis={this.apis} children={rendered} />;
}
return () => rendered;
}
getProvider(): ComponentType<{}> {
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
]);
const apis = new ApiAggregator(this.apis, appApis);
const Provider: FC<{}> = ({ children }) => (
<AppContextProvider app={this}>{children}</AppContextProvider>
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
return Provider;
}
@@ -178,8 +196,22 @@ export function createApp(options?: AppOptions) {
NotFoundErrorPage: DefaultNotFoundPage,
...options?.components,
};
const themes = options?.themes ?? [
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: lightTheme,
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: darkTheme,
},
];
const app = new AppImpl({ apis, icons, plugins, components });
const app = new AppImpl({ apis, icons, plugins, components, themes });
app.verify();
@@ -0,0 +1,97 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useMemo, useEffect, useState } from 'react';
import { ThemeProvider, CssBaseline } from '@material-ui/core';
import { useApi, appThemeApiRef, AppTheme } from '../apis';
import { useObservable } from 'react-use';
// This tries to find the most accurate match, but also falls back to less
// accurate results in order to avoid errors.
function resolveTheme(
themeId: string | undefined,
shouldPreferDark: boolean,
themes: AppTheme[],
) {
if (themeId !== undefined) {
const selectedTheme = themes.find((theme) => theme.id === themeId);
if (selectedTheme) {
return selectedTheme;
}
}
if (shouldPreferDark) {
const darkTheme = themes.find((theme) => theme.variant === 'dark');
if (darkTheme) {
return darkTheme;
}
}
const lightTheme = themes.find((theme) => theme.variant === 'light');
if (lightTheme) {
return lightTheme;
}
return themes[0];
}
const useShouldPreferDarkTheme = () => {
if (!window.matchMedia) {
return false;
}
const mediaQuery = useMemo(
() => window.matchMedia('(prefers-color-scheme: dark)'),
[],
);
const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches);
useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setPrefersDark(event.matches);
};
mediaQuery.addListener(listener);
return () => {
mediaQuery.removeListener(listener);
};
}, [mediaQuery]);
return shouldPreferDark;
};
export const AppThemeProvider: FC<{}> = ({ children }) => {
const appThemeApi = useApi(appThemeApiRef);
const shouldPreferDark = useShouldPreferDarkTheme();
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
const appTheme = resolveTheme(
themeId,
shouldPreferDark,
appThemeApi.getInstalledThemes(),
);
if (!appTheme) {
throw new Error('App has no themes');
}
return (
<ThemeProvider theme={appTheme.theme}>
<CssBaseline>{children}</CssBaseline>
</ThemeProvider>
);
};
+23
View File
@@ -18,6 +18,7 @@ import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme } from '../apis/definitions';
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
@@ -45,6 +46,28 @@ export type AppOptions = {
* Supply components to the app to override the default ones.
*/
components?: Partial<AppComponents>;
/**
* Themes provided as a part of the app. By default two themes are included, one
* light variant of the default backstage theme, and one dark.
*
* This is the default config:
*
* ```
* [{
* id: 'light',
* title: 'Light Theme',
* variant: 'light',
* theme: lightTheme,
* }, {
* id: 'dark',
* title: 'Dark Theme',
* variant: 'dark',
* theme: darkTheme,
* }]
* ```
*/
themes?: AppTheme[];
};
export type BackstageApp = {
+6 -12
View File
@@ -18,7 +18,6 @@ import React, { FC, ComponentType } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import BookmarkIcon from '@material-ui/icons/Bookmark';
import { ThemeProvider, CssBaseline } from '@material-ui/core';
import {
createApp,
SidebarPage,
@@ -30,7 +29,6 @@ import {
ApiTestRegistry,
ApiHolder,
} from '@backstage/core';
import { lightTheme } from '@backstage/theme';
import * as defaultApiFactories from './apiFactories';
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
@@ -78,16 +76,12 @@ class DevAppBuilder {
const DevApp: FC<{}> = () => {
return (
<AppProvider>
<ThemeProvider theme={lightTheme}>
<CssBaseline>
<BrowserRouter>
<SidebarPage>
{sidebar}
<AppComponent />
</SidebarPage>
</BrowserRouter>
</CssBaseline>
</ThemeProvider>
<BrowserRouter>
<SidebarPage>
{sidebar}
<AppComponent />
</SidebarPage>
</BrowserRouter>
</AppProvider>
);
};