mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
refactor(console): support offline theme adaptation (#3434)
This commit is contained in:
parent
3404684a61
commit
4a7e00940b
10 changed files with 41 additions and 74 deletions
|
@ -1,5 +1,6 @@
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AppThemeProvider } from '@/contexts/AppThemeProvider';
|
||||||
import Callback from '@cloud/pages/Callback';
|
import Callback from '@cloud/pages/Callback';
|
||||||
|
|
||||||
import * as styles from './App.module.scss';
|
import * as styles from './App.module.scss';
|
||||||
|
@ -10,14 +11,16 @@ import { CloudRoute } from './types';
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className={styles.app}>
|
<AppThemeProvider>
|
||||||
<Routes>
|
<div className={styles.app}>
|
||||||
<Route path={`/${CloudRoute.Callback}`} element={<Callback />} />
|
<Routes>
|
||||||
<Route path={`/${CloudRoute.SocialDemoCallback}`} element={<SocialDemoCallback />} />
|
<Route path={`/${CloudRoute.Callback}`} element={<Callback />} />
|
||||||
<Route path={`/:tenantId/${CloudRoute.Callback}`} element={<Callback />} />
|
<Route path={`/${CloudRoute.SocialDemoCallback}`} element={<SocialDemoCallback />} />
|
||||||
<Route path="/*" element={<Main />} />
|
<Route path={`/:tenantId/${CloudRoute.Callback}`} element={<Callback />} />
|
||||||
</Routes>
|
<Route path="/*" element={<Main />} />
|
||||||
</div>
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</AppThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { trySafe } from '@silverhand/essentials';
|
||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { useHref } from 'react-router-dom';
|
import { useHref } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
|
import AppLoading from '@/components/AppLoading';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -39,7 +39,7 @@ const Redirect = ({ tenants, toTenantId }: Props) => {
|
||||||
return <div>Forbidden</div>;
|
return <div>Forbidden</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppLoadingOffline />;
|
return <AppLoading />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Redirect;
|
export default Redirect;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { TenantInfo } from '@logto/schemas';
|
||||||
import { useCallback, useContext, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
|
import AppLoading from '@/components/AppLoading';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DangerousRaw from '@/components/DangerousRaw';
|
import DangerousRaw from '@/components/DangerousRaw';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
@ -56,7 +56,7 @@ const Tenants = ({ data, onAdd }: Props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppLoadingOffline />;
|
return <AppLoading />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tenants;
|
export default Tenants;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useContext, useEffect } from 'react';
|
||||||
import { useHref } from 'react-router-dom';
|
import { useHref } from 'react-router-dom';
|
||||||
|
|
||||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
|
import AppLoading from '@/components/AppLoading';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
|
||||||
import Redirect from './Redirect';
|
import Redirect from './Redirect';
|
||||||
|
@ -40,7 +40,7 @@ const Protected = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppLoadingOffline />;
|
return <AppLoading />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
|
@ -55,7 +55,7 @@ const Main = () => {
|
||||||
}, [href, isAuthenticated, isLoading, signIn]);
|
}, [href, isAuthenticated, isLoading, signIn]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <AppLoadingOffline />;
|
return <AppLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Protected />;
|
return <Protected />;
|
||||||
|
|
|
@ -6,8 +6,8 @@ import ErrorDark from '@/assets/images/error-dark.svg';
|
||||||
import Error from '@/assets/images/error.svg';
|
import Error from '@/assets/images/error.svg';
|
||||||
import KeyboardArrowDown from '@/assets/images/keyboard-arrow-down.svg';
|
import KeyboardArrowDown from '@/assets/images/keyboard-arrow-down.svg';
|
||||||
import KeyboardArrowUp from '@/assets/images/keyboard-arrow-up.svg';
|
import KeyboardArrowUp from '@/assets/images/keyboard-arrow-up.svg';
|
||||||
|
import useTheme from '@/hooks/use-theme';
|
||||||
import { onKeyDownHandler } from '@/utils/a11y';
|
import { onKeyDownHandler } from '@/utils/a11y';
|
||||||
import { getThemeFromLocalStorage } from '@/utils/theme';
|
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ type Props = {
|
||||||
const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props) => {
|
const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props) => {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||||
const theme = getThemeFromLocalStorage(); // Should be able to use the component in an offline environment
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { Theme } from '@logto/schemas';
|
|
||||||
|
|
||||||
import IllustrationDark from '@/assets/images/loading-illustration-dark.svg';
|
|
||||||
import Illustration from '@/assets/images/loading-illustration.svg';
|
|
||||||
import { Daisy as Spinner } from '@/components/Spinner';
|
|
||||||
import { getThemeFromLocalStorage } from '@/utils/theme';
|
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An fullscreen loading component fetches local stored theme without sending request.
|
|
||||||
*/
|
|
||||||
export const AppLoadingOffline = () => {
|
|
||||||
const theme = getThemeFromLocalStorage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{theme === Theme.Light ? <Illustration /> : <IllustrationDark />}
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { Theme } from '@logto/schemas';
|
import { Theme } from '@logto/schemas';
|
||||||
import { conditionalString } from '@silverhand/essentials';
|
import { conditionalString, trySafe } from '@silverhand/essentials';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useMemo, useState, createContext } from 'react';
|
import { useEffect, useMemo, useState, createContext } from 'react';
|
||||||
|
|
||||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
import { appearanceModeStorageKey } from '@/consts';
|
||||||
import { DynamicAppearanceMode } from '@/types/appearance-mode';
|
import type { AppearanceMode } from '@/types/appearance-mode';
|
||||||
|
import { appearanceModeGuard, DynamicAppearanceMode } from '@/types/appearance-mode';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
fixedTheme?: Theme;
|
fixedTheme?: Theme;
|
||||||
|
appearanceMode?: AppearanceMode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -23,17 +25,17 @@ const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const getThemeBySystemConfiguration = (): Theme =>
|
const getThemeBySystemConfiguration = (): Theme =>
|
||||||
darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
|
darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
|
||||||
|
|
||||||
|
export const getAppearanceModeFromLocalStorage = (): AppearanceMode =>
|
||||||
|
trySafe(() => appearanceModeGuard.parse(localStorage.getItem(appearanceModeStorageKey))) ??
|
||||||
|
DynamicAppearanceMode.System;
|
||||||
|
|
||||||
export const AppThemeContext = createContext<AppTheme>({
|
export const AppThemeContext = createContext<AppTheme>({
|
||||||
theme: defaultTheme,
|
theme: defaultTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AppThemeProvider = ({ fixedTheme, children }: Props) => {
|
export const AppThemeProvider = ({ fixedTheme, appearanceMode, children }: Props) => {
|
||||||
const [theme, setTheme] = useState<Theme>(defaultTheme);
|
const [theme, setTheme] = useState<Theme>(defaultTheme);
|
||||||
|
|
||||||
const {
|
|
||||||
data: { appearanceMode },
|
|
||||||
} = useUserPreferences();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fixedTheme) {
|
if (fixedTheme) {
|
||||||
setTheme(fixedTheme);
|
setTheme(fixedTheme);
|
||||||
|
@ -41,8 +43,11 @@ export const AppThemeProvider = ({ fixedTheme, children }: Props) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appearanceMode !== DynamicAppearanceMode.System) {
|
// Note: if the appearanceMode is not available, attempt to retrieve the last saved value from localStorage.
|
||||||
setTheme(appearanceMode);
|
const appliedAppearanceMode = appearanceMode ?? getAppearanceModeFromLocalStorage();
|
||||||
|
|
||||||
|
if (appliedAppearanceMode !== DynamicAppearanceMode.System) {
|
||||||
|
setTheme(appliedAppearanceMode);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { useEffect, useMemo } from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { appearanceModeStorageKey } from '@/consts';
|
import { appearanceModeStorageKey } from '@/consts';
|
||||||
|
import { getAppearanceModeFromLocalStorage } from '@/contexts/AppThemeProvider';
|
||||||
import { appearanceModeGuard } from '@/types/appearance-mode';
|
import { appearanceModeGuard } from '@/types/appearance-mode';
|
||||||
import { getAppearanceModeFromLocalStorage } from '@/utils/theme';
|
|
||||||
|
|
||||||
import useMeCustomData from './use-me-custom-data';
|
import useMeCustomData from './use-me-custom-data';
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import AppContent from '@/containers/AppContent';
|
||||||
import ConsoleContent from '@/containers/ConsoleContent';
|
import ConsoleContent from '@/containers/ConsoleContent';
|
||||||
import { AppThemeProvider } from '@/contexts/AppThemeProvider';
|
import { AppThemeProvider } from '@/contexts/AppThemeProvider';
|
||||||
import useSwrOptions from '@/hooks/use-swr-options';
|
import useSwrOptions from '@/hooks/use-swr-options';
|
||||||
|
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||||
import Callback from '@/pages/Callback';
|
import Callback from '@/pages/Callback';
|
||||||
import Welcome from '@/pages/Welcome';
|
import Welcome from '@/pages/Welcome';
|
||||||
|
|
||||||
|
@ -15,11 +16,14 @@ import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
||||||
|
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
const swrOptions = useSwrOptions();
|
const swrOptions = useSwrOptions();
|
||||||
|
const {
|
||||||
|
data: { appearanceMode },
|
||||||
|
} = useUserPreferences();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter basename={getBasename()}>
|
<BrowserRouter basename={getBasename()}>
|
||||||
<SWRConfig value={swrOptions}>
|
<SWRConfig value={swrOptions}>
|
||||||
<AppThemeProvider>
|
<AppThemeProvider appearanceMode={appearanceMode}>
|
||||||
<AppBoundary>
|
<AppBoundary>
|
||||||
<Toast />
|
<Toast />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { Theme } from '@logto/schemas';
|
|
||||||
import { trySafe } from '@silverhand/essentials';
|
|
||||||
|
|
||||||
import { appearanceModeStorageKey } from '@/consts';
|
|
||||||
import { appearanceModeGuard, DynamicAppearanceMode } from '@/types/appearance-mode';
|
|
||||||
import type { AppearanceMode } from '@/types/appearance-mode';
|
|
||||||
|
|
||||||
export const getTheme = (appearanceMode: AppearanceMode): Theme => {
|
|
||||||
if (appearanceMode !== DynamicAppearanceMode.System) {
|
|
||||||
return appearanceMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const theme = darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getThemeFromLocalStorage = () => getTheme(getAppearanceModeFromLocalStorage());
|
|
||||||
|
|
||||||
export const getAppearanceModeFromLocalStorage = (): AppearanceMode =>
|
|
||||||
trySafe(() => appearanceModeGuard.parse(localStorage.getItem(appearanceModeStorageKey))) ??
|
|
||||||
DynamicAppearanceMode.System;
|
|
Loading…
Add table
Reference in a new issue