0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-03 22:15:32 -05:00

refactor(console): refactor theme-related types (#3419)

This commit is contained in:
Xiao Yijun 2023-03-16 13:34:23 +08:00 committed by GitHub
parent dd4ae5b18c
commit 658d905fe0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 270 additions and 206 deletions

View file

@ -1,3 +1,4 @@
import { Theme } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -5,7 +6,6 @@ import ErrorDark from '@/assets/images/error-dark.svg';
import Error from '@/assets/images/error.svg';
import KeyboardArrowDown from '@/assets/images/keyboard-arrow-down.svg';
import KeyboardArrowUp from '@/assets/images/keyboard-arrow-up.svg';
import { Theme } from '@/types/theme';
import { onKeyDownHandler } from '@/utils/a11y';
import { getThemeFromLocalStorage } from '@/utils/theme';
@ -26,7 +26,7 @@ const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props
return (
<div className={styles.container}>
{theme === Theme.LightMode ? <Error /> : <ErrorDark />}
{theme === Theme.Light ? <Error /> : <ErrorDark />}
<label>{title ?? t('errors.something_went_wrong')}</label>
<div className={styles.summary}>
<span>

View file

@ -1,7 +1,8 @@
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 { Theme } from '@/types/theme';
import { getThemeFromLocalStorage } from '@/utils/theme';
import * as styles from './index.module.scss';
@ -14,7 +15,7 @@ export const AppLoadingOffline = () => {
return (
<div className={styles.container}>
{theme === Theme.LightMode ? <Illustration /> : <IllustrationDark />}
{theme === Theme.Light ? <Illustration /> : <IllustrationDark />}
<Spinner />
</div>
);

View file

@ -1,19 +1,18 @@
import { useContext } from 'react';
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 { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
const AppLoading = () => {
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
return (
<div className={styles.container}>
{theme === Theme.LightMode ? <Illustration /> : <IllustrationDark />}
{theme === Theme.Light ? <Illustration /> : <IllustrationDark />}
<Spinner />
</div>
);

View file

@ -1,9 +1,8 @@
import type { ApplicationType } from '@logto/schemas';
import { useContext } from 'react';
import { Theme } from '@logto/schemas';
import { darkModeApplicationIconMap, lightModeApplicationIconMap } from '@/consts';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
type Props = {
type: ApplicationType;
@ -11,8 +10,8 @@ type Props = {
};
const ApplicationIcon = ({ type, className }: Props) => {
const { theme } = useContext(AppThemeContext);
const isLightMode = theme === Theme.LightMode;
const theme = useTheme();
const isLightMode = theme === Theme.Light;
const Icon = isLightMode ? lightModeApplicationIconMap[type] : darkModeApplicationIconMap[type];
return <Icon className={className} />;

View file

@ -1,9 +1,8 @@
import type { ConnectorResponse } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import ImageWithErrorFallback from '../ImageWithErrorFallback';
import * as styles from './index.module.scss';
@ -15,14 +14,14 @@ type Props = {
};
const ConnectorLogo = ({ className, data, size = 'medium' }: Props) => {
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
return (
<ImageWithErrorFallback
containerClassName={classNames(styles.container, styles[size])}
className={classNames(styles.logo, styles[size], className)}
alt="logo"
src={theme === Theme.DarkMode && data.logoDark ? data.logoDark : data.logo}
src={theme === Theme.Dark && data.logoDark ? data.logoDark : data.logo}
/>
);
};

View file

@ -1,11 +1,10 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import EmptyDark from '@/assets/images/table-empty-dark.svg';
import Empty from '@/assets/images/table-empty.svg';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
@ -16,8 +15,8 @@ export type Props = {
const EmptyDataPlaceholder = ({ title, size = 'medium' }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const EmptyImage = theme === Theme.LightMode ? Empty : EmptyDark;
const theme = useTheme();
const EmptyImage = theme === Theme.Light ? Empty : EmptyDark;
return (
<div className={classNames(styles.empty, styles[size])}>

View file

@ -1,17 +1,17 @@
import { Theme } from '@logto/schemas';
import type { ImgHTMLAttributes } from 'react';
import { useContext, useState } from 'react';
import { useState } from 'react';
import FallbackImageDark from '@/assets/images/broken-image-dark.svg';
import FallbackImageLight from '@/assets/images/broken-image-light.svg';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
type Props = { containerClassName?: string } & ImgHTMLAttributes<HTMLImageElement>;
const ImageWithErrorFallback = ({ src, alt, className, containerClassName, ...props }: Props) => {
const [hasError, setHasError] = useState(false);
const { theme } = useContext(AppThemeContext);
const Fallback = theme === Theme.LightMode ? FallbackImageLight : FallbackImageDark;
const theme = useTheme();
const Fallback = theme === Theme.Light ? FallbackImageLight : FallbackImageDark;
const errorHandler = () => {
setHasError(true);

View file

@ -1,12 +1,11 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import RequestErrorDarkImage from '@/assets/images/request-error-dark.svg';
import RequestErrorImage from '@/assets/images/request-error.svg';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import type { RequestError } from '@/hooks/use-api';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import Button from '../Button';
import Card from '../Card';
@ -20,10 +19,10 @@ type Props = {
const RequestDataError = ({ error, onRetry, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
const errorMessage = error.body?.message ?? error.message;
const isNotFoundError = error.status === 404;
const ErrorImage = theme === Theme.LightMode ? RequestErrorImage : RequestErrorDarkImage;
const ErrorImage = theme === Theme.Light ? RequestErrorImage : RequestErrorDarkImage;
return (
<Card className={classNames(styles.error, className)}>

View file

@ -1,11 +1,11 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import Moon from '@/assets/images/moon.svg';
import Sun from '@/assets/images/sun.svg';
import { Theme } from '@/types/theme';
import type { Props as ButtonProps } from '../Button';
import Button from '../Button';
import type { Props as ButtonProps } from '../../../Button';
import Button from '../../../Button';
import * as styles from './index.module.scss';
type Props = {
@ -16,14 +16,14 @@ type Props = {
size?: ButtonProps['size'];
};
const ToggleThemeButton = ({
const ToggleUiThemeButton = ({
value,
onToggle,
className,
iconClassName,
size = 'medium',
}: Props) => {
const ThemeIcon = value === Theme.LightMode ? Sun : Moon;
const ThemeIcon = value === Theme.Light ? Sun : Moon;
return (
<div className={classNames(styles.container, styles[size])}>
@ -32,11 +32,11 @@ const ToggleThemeButton = ({
className={classNames(styles.button, className)}
icon={<ThemeIcon className={classNames(styles.icon, iconClassName)} />}
onClick={() => {
onToggle(value === Theme.LightMode ? Theme.DarkMode : Theme.LightMode);
onToggle(value === Theme.Light ? Theme.Dark : Theme.Light);
}}
/>
</div>
);
};
export default ToggleThemeButton;
export default ToggleUiThemeButton;

View file

@ -1,6 +1,6 @@
import type { LanguageTag } from '@logto/language-kit';
import type { ConnectorMetadata, ConnectorResponse, SignInExperience } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { Theme, ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { format } from 'date-fns';
@ -12,11 +12,12 @@ import PhoneInfo from '@/assets/images/phone-info.svg';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import type { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
import { Theme } from '@/types/theme';
import * as styles from './index.module.scss';
import { PreviewPlatform } from './types';
export { default as ToggleUiThemeButton } from './components/ToggleUiThemeButton';
type Props = {
platform: PreviewPlatform;
mode: Theme;
@ -114,7 +115,7 @@ const SignInExperiencePreview = ({ platform, mode, language = 'en', signInExperi
style={conditional(
platform === PreviewPlatform.DesktopWeb && {
// Set background color to match iframe's background color on both dark and light mode.
backgroundColor: mode === Theme.DarkMode ? '#000' : '#e5e1ec',
backgroundColor: mode === Theme.Dark ? '#000' : '#e5e1ec',
}
)}
>

View file

@ -1,10 +1,9 @@
import { useContext } from 'react';
import { Theme } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import RequestErrorDarkImage from '@/assets/images/request-error-dark.svg';
import RequestErrorImage from '@/assets/images/request-error.svg';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import Button from '../Button';
import * as styles from './TableError.module.scss';
@ -18,13 +17,13 @@ type Props = {
const TableError = ({ title, content, onRetry, columns }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
return (
<tr>
<td colSpan={columns}>
<div className={styles.tableError}>
{theme === Theme.LightMode ? <RequestErrorImage /> : <RequestErrorDarkImage />}
{theme === Theme.Light ? <RequestErrorImage /> : <RequestErrorDarkImage />}
<div className={styles.title}>{title ?? t('errors.something_went_wrong')}</div>
<div className={styles.content}>{content ?? t('errors.unknown_server_error')}</div>
{onRetry && <Button title="general.retry" onClick={onRetry} />}

View file

@ -1,10 +1,9 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { Theme } from '@logto/schemas';
import type { ReactNode } from 'react';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import TextLink from '../TextLink';
import * as styles from './TablePlaceholder.module.scss';
@ -27,11 +26,11 @@ const TablePlaceholder = ({
action,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
return (
<div className={styles.placeholder}>
<div className={styles.image}>{theme === Theme.LightMode ? image : imageDark}</div>
<div className={styles.image}>{theme === Theme.Light ? image : imageDark}</div>
<div className={styles.title}>{t(title)}</div>
<div className={styles.description}>
{t(description)}

View file

@ -1,11 +1,10 @@
import { Theme } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import { useContext } from 'react';
import DarkAvatar from '@/assets/images/default-avatar-dark.svg';
import LightAvatar from '@/assets/images/default-avatar-light.svg';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import ImageWithErrorFallback from '../ImageWithErrorFallback';
import * as styles from './index.module.scss';
@ -17,8 +16,8 @@ type Props = {
};
const UserAvatar = ({ className, url, size = 'medium' }: Props) => {
const { theme } = useContext(AppThemeContext);
const DefaultAvatar = theme === Theme.LightMode ? LightAvatar : DarkAvatar;
const theme = useTheme();
const DefaultAvatar = theme === Theme.Light ? LightAvatar : DarkAvatar;
const avatarClassName = classNames(styles.avatar, styles[size], className);
if (url) {

View file

@ -6,7 +6,7 @@ export * from './tenants';
export * from './page-tabs';
export * from './external-links';
export const themeStorageKey = 'logto:admin_console:theme';
export const appearanceModeStorageKey = 'logto:admin_console:appearance_mode';
export const profileSocialLinkingKeyPrefix = 'logto:admin_console:linking_social_connector';
export const requestTimeout = 20_000;
export const defaultPageSize = 20;

View file

@ -1,6 +1,6 @@
import { builtInLanguageOptions as consoleBuiltInLanguageOptions } from '@logto/phrases';
import { useLogto } from '@logto/react';
import { AppearanceMode } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -19,6 +19,7 @@ import UserInfoCard from '@/components/UserInfoCard';
import { getSignOutRedirectPathname } from '@/consts';
import useCurrentUser from '@/hooks/use-current-user';
import useUserPreferences from '@/hooks/use-user-preferences';
import { DynamicAppearanceMode } from '@/types/appearance-mode';
import { onKeyDownHandler } from '@/utils/a11y';
import SubMenu from '../SubMenu';
@ -98,15 +99,15 @@ const UserInfo = () => {
title="menu.appearance.label"
options={[
{
value: AppearanceMode.SyncWithSystem,
value: DynamicAppearanceMode.System,
title: t('menu.appearance.system'),
},
{
value: AppearanceMode.LightMode,
value: Theme.Light,
title: t('menu.appearance.light'),
},
{
value: AppearanceMode.DarkMode,
value: Theme.Dark,
title: t('menu.appearance.dark'),
},
]}

View file

@ -1,5 +1,5 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { useContext } from 'react';
import { Theme } from '@logto/schemas';
import DiscordDark from '@/assets/images/discord-dark.svg';
import Discord from '@/assets/images/discord.svg';
@ -8,8 +8,7 @@ import Email from '@/assets/images/email.svg';
import GithubDark from '@/assets/images/github-dark.svg';
import Github from '@/assets/images/github.svg';
import { contactEmailLink, discordLink, githubIssuesLink } from '@/consts';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
type ContactItem = {
icon: SvgComponent;
@ -20,8 +19,8 @@ type ContactItem = {
};
export const useContacts = (): ContactItem[] => {
const { theme } = useContext(AppThemeContext);
const isLightMode = theme === Theme.LightMode;
const theme = useTheme();
const isLightMode = theme === Theme.Light;
return [
{

View file

@ -1,10 +1,10 @@
import { AppearanceMode } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState, createContext } from 'react';
import useUserPreferences from '@/hooks/use-user-preferences';
import { Theme } from '@/types/theme';
import { DynamicAppearanceMode } from '@/types/appearance-mode';
import * as styles from './index.module.scss';
@ -17,16 +17,18 @@ type AppTheme = {
theme: Theme;
};
const defaultTheme: Theme = Theme.Light;
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
const getThemeBySystemConfiguration = (): Theme =>
darkThemeWatchMedia.matches ? Theme.DarkMode : Theme.LightMode;
darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
export const AppThemeContext = createContext<AppTheme>({
theme: Theme.LightMode,
theme: defaultTheme,
});
export const AppThemeProvider = ({ fixedTheme, children }: Props) => {
const [theme, setTheme] = useState<Theme>(Theme.LightMode);
const [theme, setTheme] = useState<Theme>(defaultTheme);
const {
data: { appearanceMode },
@ -39,8 +41,8 @@ export const AppThemeProvider = ({ fixedTheme, children }: Props) => {
return;
}
if (appearanceMode !== AppearanceMode.SyncWithSystem) {
setTheme(appearanceMode === AppearanceMode.LightMode ? Theme.LightMode : Theme.DarkMode);
if (appearanceMode !== DynamicAppearanceMode.System) {
setTheme(appearanceMode);
return;
}

View file

@ -0,0 +1,11 @@
import { useContext } from 'react';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
const useTheme = () => {
const { theme } = useContext(AppThemeContext);
return theme;
};
export default useTheme;

View file

@ -1,10 +1,10 @@
import { builtInLanguages as builtInConsoleLanguages } from '@logto/phrases';
import { AppearanceMode } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials';
import { useEffect, useMemo } from 'react';
import { z } from 'zod';
import { themeStorageKey } from '@/consts';
import { appearanceModeStorageKey } from '@/consts';
import { appearanceModeGuard } from '@/types/appearance-mode';
import { getAppearanceModeFromLocalStorage } from '@/utils/theme';
import useMeCustomData from './use-me-custom-data';
@ -12,7 +12,7 @@ const adminConsolePreferencesKey = 'adminConsolePreferences';
const userPreferencesGuard = z.object({
language: z.enum(builtInConsoleLanguages).optional(),
appearanceMode: z.nativeEnum(AppearanceMode),
appearanceMode: appearanceModeGuard,
experienceNoticeConfirmed: z.boolean().optional(),
getStartedHidden: z.boolean().optional(),
connectorSieNoticeConfirmed: z.boolean().optional(),
@ -20,11 +20,6 @@ const userPreferencesGuard = z.object({
export type UserPreferences = z.infer<typeof userPreferencesGuard>;
const getEnumFromArray = <T extends string>(
array: T[],
value: Nullable<Optional<string>>
): Optional<T> => array.find((element) => element === value);
const useUserPreferences = () => {
const { data, error, isLoading, isLoaded, update: updateMeCustomData } = useMeCustomData();
@ -34,11 +29,7 @@ const useUserPreferences = () => {
return parsed.success
? parsed.data[adminConsolePreferencesKey]
: {
appearanceMode:
getEnumFromArray(
Object.values(AppearanceMode),
localStorage.getItem(themeStorageKey)
) ?? AppearanceMode.SyncWithSystem,
appearanceMode: getAppearanceModeFromLocalStorage(),
};
}, [data]);
@ -52,7 +43,7 @@ const useUserPreferences = () => {
};
useEffect(() => {
localStorage.setItem(themeStorageKey, userPreferences.appearanceMode);
localStorage.setItem(appearanceModeStorageKey, userPreferences.appearanceMode);
}, [userPreferences.appearanceMode]);
return {

View file

@ -1,3 +1,4 @@
import { Theme } from '@logto/schemas';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { SWRConfig } from 'swr';
@ -8,7 +9,6 @@ import AppBoundary from '@/containers/AppBoundary';
import { AppThemeProvider } from '@/contexts/AppThemeProvider';
import useSwrOptions from '@/hooks/use-swr-options';
import NotFound from '@/pages/NotFound';
import { Theme } from '@/types/theme';
import * as styles from './App.module.scss';
import AppContent from './containers/AppContent';
@ -38,7 +38,7 @@ const App = () => {
<BrowserRouter basename={getBasename()}>
<div className={styles.app}>
<SWRConfig value={swrOptions}>
<AppThemeProvider fixedTheme={Theme.LightMode}>
<AppThemeProvider fixedTheme={Theme.Light}>
<AppBoundary>
<Toast />
<Routes>

View file

@ -1,4 +1,5 @@
import type { SignInExperience } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useState } from 'react';
@ -6,7 +7,6 @@ import { useState } from 'react';
import LivePreviewButton from '@/components/LivePreviewButton';
import SignInExperiencePreview from '@/components/SignInExperiencePreview';
import { PreviewPlatform } from '@/components/SignInExperiencePreview/types';
import { Theme } from '@/types/theme';
import PlatformTabs from '../PlatformTabs';
import * as styles from './index.module.scss';
@ -32,7 +32,7 @@ const Preview = ({ signInExperience, isLivePreviewDisabled = false, className }:
</div>
<SignInExperiencePreview
platform={currentTab}
mode={Theme.LightMode}
mode={Theme.Light}
signInExperience={signInExperience}
/>
</div>

View file

@ -1,7 +1,7 @@
import type { Resource } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import { defaultManagementApi, Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
@ -21,11 +21,10 @@ import RequestDataError from '@/components/RequestDataError';
import TabNav, { TabNavItem } from '@/components/TabNav';
import TextLink from '@/components/TextLink';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTheme from '@/hooks/use-theme';
import * as detailsStyles from '@/scss/details.module.scss';
import { Theme } from '@/types/theme';
import * as styles from './index.module.scss';
import { ApiResourceDetailsOutletContext } from './types';
@ -37,8 +36,8 @@ const ApiResourceDetails = () => {
const navigate = useNavigate();
const { data, error, mutate } = useSWR<Resource, RequestError>(id && `api/resources/${id}`);
const isLoading = !data && !error;
const { theme } = useContext(AppThemeContext);
const Icon = theme === Theme.LightMode ? ApiResource : ApiResourceDark;
const theme = useTheme();
const Icon = theme === Theme.Light ? ApiResource : ApiResourceDark;
const isOnPermissionPage = pathname.endsWith(ApiResourceDetailsTabs.Permissions);
const isLogtoManagementApiResource = data?.indicator === defaultManagementApi.resource.indicator;

View file

@ -1,5 +1,5 @@
import type { Resource } from '@logto/schemas';
import { useContext } from 'react';
import { Theme } from '@logto/schemas';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
@ -18,12 +18,11 @@ import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import { defaultPageSize } from '@/consts';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import type { RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import useTheme from '@/hooks/use-theme';
import * as modalStyles from '@/scss/modal.module.scss';
import * as resourcesStyles from '@/scss/resources.module.scss';
import { Theme } from '@/types/theme';
import { buildUrl } from '@/utils/url';
import CreateForm from './components/CreateForm';
@ -53,10 +52,10 @@ const ApiResources = () => {
const isLoading = !data && !error;
const navigate = useNavigate();
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
const [apiResources, totalCount] = data ?? [];
const ResourceIcon = theme === Theme.LightMode ? ApiResource : ApiResourceDark;
const ResourceIcon = theme === Theme.Light ? ApiResource : ApiResourceDark;
return (
<div className={resourcesStyles.container}>

View file

@ -1,5 +1,6 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CongratsDark from '@/assets/images/congrats-dark.svg';
@ -11,9 +12,8 @@ import Card from '@/components/Card';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Select from '@/components/Select';
import Spacer from '@/components/Spacer';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import useTheme from '@/hooks/use-theme';
import type { SupportedSdk } from '@/types/applications';
import { Theme } from '@/types/theme';
import * as styles from './index.module.scss';
@ -36,8 +36,8 @@ const SdkSelector = ({
}: Props) => {
const [isFolded, setIsFolded] = useState(isCompact);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const isLightMode = theme === Theme.LightMode;
const theme = useTheme();
const isLightMode = theme === Theme.Light;
const CongratsIcon = isLightMode ? Congrats : CongratsDark;
const TadaIcon = isLightMode ? Tada : TadaDark;

View file

@ -1,14 +1,14 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import TadaDark from '@/assets/images/tada-dark.svg';
import Tada from '@/assets/images/tada.svg';
import Dropdown, { DropdownItem } from '@/components/Dropdown';
import Index from '@/components/Index';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import useTheme from '@/hooks/use-theme';
import useUserPreferences from '@/hooks/use-user-preferences';
import { Theme } from '@/types/theme';
import { onKeyDownHandler } from '@/utils/a11y';
import useGetStartedMetadata from '../../hook';
@ -19,8 +19,8 @@ const GetStartedProgress = () => {
const {
data: { getStartedHidden },
} = useUserPreferences();
const { theme } = useContext(AppThemeContext);
const Icon = theme === Theme.LightMode ? Tada : TadaDark;
const theme = useTheme();
const Icon = theme === Theme.Light ? Tada : TadaDark;
const anchorRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const { data, completedCount, totalCount } = useGetStartedMetadata();

View file

@ -1,4 +1,5 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { Theme } from '@logto/schemas';
import { useContext, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
@ -20,9 +21,8 @@ import { discordLink, githubLink } from '@/consts';
import { isCloud } from '@/consts/cloud';
import { ConnectorsTabs } from '@/consts/page-tabs';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import useConfigs from '@/hooks/use-configs';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
type GetStartedMetadata = {
id: string;
@ -38,8 +38,8 @@ type GetStartedMetadata = {
const useGetStartedMetadata = () => {
const { configs, updateConfigs } = useConfigs();
const { userEndpoint } = useContext(AppEndpointsContext);
const { theme } = useContext(AppThemeContext);
const isLightMode = theme === Theme.LightMode;
const theme = useTheme();
const isLightMode = theme === Theme.Light;
const navigate = useNavigate();
const data = useMemo(() => {

View file

@ -1,22 +1,21 @@
import { useContext } from 'react';
import { Theme } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import NotFoundDarkImage from '@/assets/images/not-found-dark.svg';
import NotFoundImage from '@/assets/images/not-found.svg';
import Card from '@/components/Card';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
const NotFound = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
return (
<div className={styles.container}>
<Card className={styles.content}>
{theme === Theme.LightMode ? <NotFoundImage /> : <NotFoundDarkImage />}
{theme === Theme.Light ? <NotFoundImage /> : <NotFoundDarkImage />}
<div className={styles.message}>{t('errors.page_not_found')}</div>
</Card>
</div>

View file

@ -1,8 +1,9 @@
import { buildIdGenerator } from '@logto/core-kit';
import type { ConnectorResponse, UserInfo } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { appendPath, conditional } from '@silverhand/essentials';
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { is } from 'superstruct';
@ -13,12 +14,11 @@ import ImageWithErrorFallback from '@/components/ImageWithErrorFallback';
import UnnamedTrans from '@/components/UnnamedTrans';
import UserInfoCard from '@/components/UserInfoCard';
import { adminTenantEndpoint, getBasename, meApi, profileSocialLinkingKeyPrefix } from '@/consts';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { useStaticApi } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useTheme from '@/hooks/use-theme';
import type { SocialUserInfo } from '@/types/profile';
import { socialUserInfoGuard } from '@/types/profile';
import { Theme } from '@/types/theme';
import { popupWindow } from '../../utils';
import type { Row } from '../CardContent';
@ -35,7 +35,7 @@ type Props = {
const LinkAccountSection = ({ user, connectors, onUpdate }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
const { show: showConfirm } = useConfirmModal();
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
@ -61,7 +61,7 @@ const LinkAccountSection = ({ user, connectors, onUpdate }: Props) => {
}
return connectors.map(({ id, name, logo, logoDark, target }) => {
const logoSrc = theme === Theme.DarkMode && logoDark ? logoDark : logo;
const logoSrc = theme === Theme.Dark && logoDark ? logoDark : logo;
const relatedUserDetails = user.identities?.[target]?.details;
const hasLinked = is(relatedUserDetails, socialUserInfoGuard);

View file

@ -1,18 +1,17 @@
import type { LanguageTag } from '@logto/language-kit';
import { languages as uiLanguageNameMapping } from '@logto/language-kit';
import type { SignInExperience } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LivePreviewButton from '@/components/LivePreviewButton';
import Select from '@/components/Select';
import SignInExperiencePreview from '@/components/SignInExperiencePreview';
import SignInExperiencePreview, { ToggleUiThemeButton } from '@/components/SignInExperiencePreview';
import { PreviewPlatform } from '@/components/SignInExperiencePreview/types';
import TabNav, { TabNavItem } from '@/components/TabNav';
import ToggleThemeButton from '@/components/ToggleThemeButton';
import useUiLanguages from '@/hooks/use-ui-languages';
import { Theme } from '@/types/theme';
import * as styles from './index.module.scss';
@ -31,13 +30,13 @@ const Preview = ({
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [language, setLanguage] = useState<LanguageTag>('en');
const [mode, setMode] = useState<Theme>(Theme.LightMode);
const [mode, setMode] = useState<Theme>(Theme.Light);
const [platform, setPlatform] = useState<PreviewPlatform>(PreviewPlatform.DesktopWeb);
const { languages } = useUiLanguages();
useEffect(() => {
if (!signInExperience?.color.isDarkModeEnabled) {
setMode(Theme.LightMode);
setMode(Theme.Light);
}
}, [mode, signInExperience]);
@ -71,7 +70,7 @@ const Preview = ({
<div className={styles.title}>{t('sign_in_exp.preview.title')}</div>
<div className={styles.selects}>
{signInExperience?.color.isDarkModeEnabled && (
<ToggleThemeButton value={mode} size="small" onToggle={setMode} />
<ToggleUiThemeButton value={mode} size="small" onToggle={setMode} />
)}
<Select
className={styles.language}

View file

@ -1,11 +1,11 @@
import { useContext, useState } from 'react';
import { Theme } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import WelcomeImageDark from '@/assets/images/sign-in-experience-welcome-dark.svg';
import WelcomeImage from '@/assets/images/sign-in-experience-welcome.svg';
import Button from '@/components/Button';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import { Theme } from '@/types/theme';
import useTheme from '@/hooks/use-theme';
import GuideModal from './GuideModal';
import * as styles from './index.module.scss';
@ -17,16 +17,13 @@ type Props = {
const Welcome = ({ mutate }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isOpen, setIsOpen] = useState(false);
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
const WelcomeIcon = theme === Theme.Light ? WelcomeImage : WelcomeImageDark;
return (
<div className={styles.container}>
<div className={styles.content}>
{theme === Theme.LightMode ? (
<WelcomeImage className={styles.icon} />
) : (
<WelcomeImageDark className={styles.icon} />
)}
<WelcomeIcon className={styles.icon} />
<div className={styles.wrapper}>
<div className={styles.title}>{t('sign_in_exp.welcome.title')}</div>
<div className={styles.description}>{t('sign_in_exp.welcome.description')}</div>

View file

@ -1,6 +1,6 @@
import { LogtoClientError, useLogto } from '@logto/react';
import classNames from 'classnames';
import { useContext, useEffect } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useHref } from 'react-router-dom';
@ -8,7 +8,7 @@ import Logo from '@/assets/images/logo.svg';
import AppError from '@/components/AppError';
import Button from '@/components/Button';
import SessionExpired from '@/components/SessionExpired';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
@ -17,7 +17,7 @@ const Welcome = () => {
const navigate = useNavigate();
const { isAuthenticated, error, signIn } = useLogto();
const href = useHref('/callback');
const { theme } = useContext(AppThemeContext);
const theme = useTheme();
useEffect(() => {
// If Authenticated, navigate to the Admin Console root page. directly

View file

@ -0,0 +1,10 @@
import { Theme } from '@logto/schemas';
import { z } from 'zod';
export enum DynamicAppearanceMode {
System = 'system',
}
export const appearanceModeGuard = z.nativeEnum(Theme).or(z.nativeEnum(DynamicAppearanceMode));
export type AppearanceMode = z.infer<typeof appearanceModeGuard>;

View file

@ -1,4 +0,0 @@
export enum Theme {
LightMode = 'light',
DarkMode = 'dark',
}

View file

@ -1,23 +1,23 @@
import { AppearanceMode } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { z } from 'zod';
import { themeStorageKey } from '@/consts';
import { Theme } from '@/types/theme';
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 !== AppearanceMode.SyncWithSystem) {
return appearanceMode === AppearanceMode.LightMode ? Theme.LightMode : Theme.DarkMode;
if (appearanceMode !== DynamicAppearanceMode.System) {
return appearanceMode;
}
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
const theme = darkThemeWatchMedia.matches ? Theme.DarkMode : Theme.LightMode;
const theme = darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
return theme;
};
export const getThemeFromLocalStorage = () =>
getTheme(
trySafe(() => z.nativeEnum(AppearanceMode).parse(localStorage.getItem(themeStorageKey))) ??
AppearanceMode.SyncWithSystem
);
export const getThemeFromLocalStorage = () => getTheme(getAppearanceModeFromLocalStorage());
export const getAppearanceModeFromLocalStorage = (): AppearanceMode =>
trySafe(() => appearanceModeGuard.parse(localStorage.getItem(appearanceModeStorageKey))) ??
DynamicAppearanceMode.System;

View file

@ -0,0 +1,84 @@
import type { DatabaseTransactionConnection } from 'slonik';
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const adminConsoleConfigKey = 'adminConsole';
const defaultAppearanceMode = 'system';
const defaultLanguage = 'en';
type OldAdminConsoleData = {
language: string;
appearanceMode: string;
} & Record<string, unknown>;
type OldLogtoAdminConsoleConfig = {
tenantId: string;
value: OldAdminConsoleData;
};
type NewAdminConsoleData = Omit<OldAdminConsoleData, 'language' | 'appearanceMode'>;
type NewLogtoAdminConsoleConfig = {
tenantId: string;
value: NewAdminConsoleData;
};
const alterAdminConsoleData = async (
logtoConfig: OldLogtoAdminConsoleConfig,
pool: DatabaseTransactionConnection
) => {
const { tenantId, value: oldAdminConsoleData } = logtoConfig;
const {
language, // Extract to remove from config
appearanceMode, // Extract to remove from config
...others
} = oldAdminConsoleData;
const newAdminConsoleData: NewAdminConsoleData = {
...others,
};
await pool.query(
sql`update logto_configs set value = ${JSON.stringify(
newAdminConsoleData
)} where tenant_id = ${tenantId} and key = ${adminConsoleConfigKey}`
);
};
const rollbackAdminConsoleData = async (
logtoConfig: NewLogtoAdminConsoleConfig,
pool: DatabaseTransactionConnection
) => {
const { tenantId, value: newAdminConsoleData } = logtoConfig;
const oldAdminConsoleData: OldAdminConsoleData = {
...newAdminConsoleData,
language: defaultLanguage,
appearanceMode: defaultAppearanceMode,
};
await pool.query(
sql`update logto_configs set value = ${JSON.stringify(
oldAdminConsoleData
)} where tenant_id = ${tenantId} and key = ${adminConsoleConfigKey}`
);
};
const alteration: AlterationScript = {
up: async (pool) => {
const rows = await pool.many<OldLogtoAdminConsoleConfig>(
sql`select * from logto_configs where key = ${adminConsoleConfigKey}`
);
await Promise.all(rows.map(async (row) => alterAdminConsoleData(row, pool)));
},
down: async (pool) => {
const rows = await pool.many<NewLogtoAdminConsoleConfig>(
sql`select * from logto_configs where key = ${adminConsoleConfigKey}`
);
await Promise.all(rows.map(async (row) => rollbackAdminConsoleData(row, pool)));
},
};
export default alteration;

View file

@ -147,18 +147,6 @@ export const customContentGuard = z.record(z.string());
export type CustomContent = z.infer<typeof customContentGuard>;
/* === Logto Configs === */
/**
* Settings
*/
export enum AppearanceMode {
SyncWithSystem = 'system',
LightMode = 'light',
DarkMode = 'dark',
}
/* === Phrases === */
export type Translation = {

View file

@ -1,5 +1,4 @@
import { CreateLogtoConfig } from '../db-entries/index.js';
import { AppearanceMode } from '../foundations/index.js';
import type { AdminConsoleData } from '../types/index.js';
import { LogtoTenantConfigKey } from '../types/index.js';
@ -14,8 +13,6 @@ export const createDefaultAdminConsoleConfig = (
tenantId: forTenantId,
key: LogtoTenantConfigKey.AdminConsole,
value: {
language: 'en',
appearanceMode: AppearanceMode.SyncWithSystem,
livePreviewChecked: false,
applicationCreated: false,
signInExperienceCustomized: false,

View file

@ -15,3 +15,4 @@ export * from './tenant.js';
export * from './user-assets.js';
export * from './hook.js';
export * from './service-log.js';
export * from './theme.js';

View file

@ -0,0 +1,4 @@
export enum Theme {
Light = 'light',
Dark = 'dark',
}

View file

@ -1,3 +1,4 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
@ -20,6 +21,7 @@ type Props = {
const LogtoSignature = ({ className }: Props) => {
const { theme } = useContext(PageContext);
const LogtoLogo = theme === Theme.Light ? LogtoLogoLight : LogtoLogtoDark;
return (
<a
@ -31,11 +33,7 @@ const LogtoSignature = ({ className }: Props) => {
>
<span className={styles.text}>Powered by</span>
<LogtoLogoShadow className={styles.staticIcon} />
{theme === 'light' ? (
<LogtoLogoLight className={styles.highlightIcon} />
) : (
<LogtoLogtoDark className={styles.highlightIcon} />
)}
<LogtoLogo className={styles.highlightIcon} />
</a>
);
};

View file

@ -1,3 +1,4 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
@ -21,7 +22,7 @@ const SocialLanding = ({ className, connectorId, isLoading = false }: Props) =>
<div className={styles.connector}>
{connector ? (
<img
src={theme === 'dark' ? connector.logoDark ?? connector.logo : connector.logo}
src={theme === Theme.Dark ? connector.logoDark ?? connector.logo : connector.logo}
alt="logo"
/>
) : (

View file

@ -1,4 +1,5 @@
import { absoluteDarken, absoluteLighten } from '@logto/core-kit';
import { Theme } from '@logto/schemas';
import color from 'color';
import { useEffect, useContext } from 'react';
@ -34,7 +35,7 @@ const useColorTheme = () => {
const lightPrimary = color(primaryColor);
if (theme === 'light') {
if (theme === Theme.Light) {
const lightColorLibrary = generateLightColorLibrary(lightPrimary);
for (const [key, value] of Object.entries(lightColorLibrary)) {

View file

@ -1,8 +1,9 @@
import { Theme } from '@logto/schemas';
import { noop } from '@silverhand/essentials';
import { useState, useMemo, createContext } from 'react';
import { isMobile } from 'react-device-detect';
import type { SignInExperienceResponse, Platform, Theme } from '@/types';
import type { SignInExperienceResponse, Platform } from '@/types';
import { parseQueryParameters } from '@/utils';
export type Context = {
@ -23,7 +24,7 @@ export type Context = {
export const PageContext = createContext<Context>({
toast: '',
theme: 'light',
theme: Theme.Light,
loading: false,
platform: isMobile ? 'mobile' : 'web',
termsAgreement: false,
@ -40,7 +41,7 @@ export const PageContext = createContext<Context>({
const usePageContext = () => {
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState('');
const [theme, setTheme] = useState<Theme>('light');
const [theme, setTheme] = useState<Theme>(Theme.Light);
const [platform, setPlatform] = useState<Platform>(isMobile ? 'mobile' : 'web');
const [experienceSettings, setExperienceSettings] = useState<SignInExperienceResponse>();
const [termsAgreement, setTermsAgreement] = useState(false);

View file

@ -1,11 +1,11 @@
import { Theme } from '@logto/schemas';
import { useEffect, useContext } from 'react';
import type { Theme } from '@/types';
import { PageContext } from './use-page-context';
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light');
const getThemeBySystemConfiguration = (): Theme =>
darkThemeWatchMedia.matches ? Theme.Dark : Theme.Light;
export default function useTheme(): Theme {
const { isPreview, experienceSettings, theme, setTheme } = useContext(PageContext);

View file

@ -1,3 +1,4 @@
import { Theme } from '@logto/schemas';
import { useContext } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
@ -30,7 +31,7 @@ const ErrorPage = ({ title = 'description.not_found', message, rawMessage, isRoo
<StaticPageLayout>
{!isRootPath && <NavBar />}
<div className={styles.container}>
{theme === 'light' ? <EmptyState /> : <EmptyStateDark />}
{theme === Theme.Light ? <EmptyState /> : <EmptyStateDark />}
<div className={styles.title}>{t(title)}</div>
{errorMessage && <div className={styles.message}>{String(errorMessage)}</div>}
</div>

View file

@ -1,9 +1,4 @@
import type {
SignInExperience,
ConnectorMetadata,
AppearanceMode,
SignInIdentifier,
} from '@logto/schemas';
import type { SignInExperience, ConnectorMetadata, SignInIdentifier, Theme } from '@logto/schemas';
export enum UserFlow {
signIn = 'sign-in',
@ -20,9 +15,6 @@ export enum SearchParameters {
export type Platform = 'web' | 'mobile';
// TODO: @simeng, @sijie, @charles should we combine this with admin console?
export type Theme = 'dark' | 'light';
export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone;
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors
@ -42,7 +34,7 @@ export enum ConfirmModalMessage {
export type PreviewConfig = {
signInExperience: SignInExperienceResponse;
language: string;
mode: AppearanceMode.LightMode | AppearanceMode.DarkMode;
mode: Theme;
platform: Platform;
isNative: boolean;
};

View file

@ -1,8 +1,7 @@
import type { Branding } from '@logto/schemas';
import { Theme } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { Theme } from '@/types';
export type GetLogoUrl = {
theme: Theme;
logoUrl: string;
@ -11,7 +10,7 @@ export type GetLogoUrl = {
};
export const getLogoUrl = ({ theme, logoUrl, darkLogoUrl, isApple }: GetLogoUrl) => {
if (theme === (isApple ? 'light' : 'dark')) {
if (theme === (isApple ? Theme.Light : Theme.Dark)) {
return darkLogoUrl ?? logoUrl;
}