mirror of
https://github.com/logto-io/logto.git
synced 2025-02-24 22:05:56 -05:00
refactor(ui): use postMessage on preview (#929)
* refactor(ui): use postMessage on preview use postMessage on preview * fix(ui): cr fix cr fix * fix(ui): cr fix cr fix * fix(ui): cr fix cr fix
This commit is contained in:
parent
10d30ee309
commit
cd18a7a046
8 changed files with 118 additions and 81 deletions
|
@ -2,7 +2,7 @@ import { Language } from '@logto/phrases';
|
|||
import { AppearanceMode, ConnectorDTO, ConnectorMetadata, SignInExperience } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -25,10 +25,11 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
const [mode, setMode] = useState<AppearanceMode>(AppearanceMode.LightMode);
|
||||
const [platform, setPlatform] = useState<'web' | 'mobile'>('mobile');
|
||||
const { data: allConnectors } = useSWR<ConnectorDTO[], RequestError>('/api/connectors');
|
||||
const previewRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (!allConnectors) {
|
||||
return '';
|
||||
return;
|
||||
}
|
||||
|
||||
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
|
||||
|
@ -43,19 +44,40 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
[]
|
||||
);
|
||||
|
||||
return encodeURIComponent(
|
||||
JSON.stringify({
|
||||
signInExperience: {
|
||||
...signInExperience,
|
||||
socialConnectors,
|
||||
},
|
||||
language,
|
||||
mode,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
return {
|
||||
signInExperience: {
|
||||
...signInExperience,
|
||||
socialConnectors,
|
||||
},
|
||||
language,
|
||||
mode,
|
||||
platform,
|
||||
};
|
||||
}, [allConnectors, language, mode, platform, signInExperience]);
|
||||
|
||||
const postPreviewMessage = useCallback(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRef.current?.contentWindow?.postMessage(
|
||||
{ sender: 'ac_preview', config },
|
||||
window.location.origin
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
postPreviewMessage();
|
||||
|
||||
const iframe = previewRef.current;
|
||||
|
||||
iframe?.addEventListener('load', postPreviewMessage);
|
||||
|
||||
return () => {
|
||||
iframe?.removeEventListener('load', postPreviewMessage);
|
||||
};
|
||||
}, [postPreviewMessage]);
|
||||
|
||||
return (
|
||||
<Card className={classNames(styles.preview, className)}>
|
||||
<div className={styles.header}>
|
||||
|
@ -109,7 +131,7 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
<img src={TopInfoImage} />
|
||||
</div>
|
||||
)}
|
||||
<iframe src={`/sign-in?config=${config}&preview=true`} />
|
||||
<iframe ref={previewRef} src="/sign-in?preview=true" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
9
packages/ui/src/App.module.scss
Normal file
9
packages/ui/src/App.module.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.preview {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
main {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import { conditionalString } from '@silverhand/essentials';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
import AppContent from './components/AppContent';
|
||||
import usePageContext from './hooks/use-page-context';
|
||||
import usePreview from './hooks/use-preview';
|
||||
|
@ -13,24 +15,26 @@ import Register from './pages/Register';
|
|||
import SecondarySignIn from './pages/SecondarySignIn';
|
||||
import SignIn from './pages/SignIn';
|
||||
import SocialRegister from './pages/SocialRegister';
|
||||
import getSignInExperienceSettings, {
|
||||
parseSignInExperienceSettings,
|
||||
} from './utils/sign-in-experience';
|
||||
import getSignInExperienceSettings from './utils/sign-in-experience';
|
||||
|
||||
import './scss/normalized.scss';
|
||||
|
||||
const App = () => {
|
||||
const { context, Provider } = usePageContext();
|
||||
const { experienceSettings, setLoading, setExperienceSettings } = context;
|
||||
const [isPreview, previewSettings] = usePreview();
|
||||
const [isPreview] = usePreview(context);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreview) {
|
||||
document.body.classList.add(conditionalString(styles.preview));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
|
||||
const settings = previewSettings
|
||||
? parseSignInExperienceSettings(previewSettings.signInExperience)
|
||||
: await getSignInExperienceSettings();
|
||||
const settings = await getSignInExperienceSettings();
|
||||
|
||||
// Note: i18n must be initialized ahead of global experience settings
|
||||
await initI18n(settings.languageInfo);
|
||||
|
@ -39,7 +43,7 @@ const App = () => {
|
|||
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [isPreview, previewSettings, setExperienceSettings, setLoading]);
|
||||
}, [isPreview, setExperienceSettings, setLoading]);
|
||||
|
||||
if (!experienceSettings) {
|
||||
return null;
|
||||
|
@ -47,7 +51,7 @@ const App = () => {
|
|||
|
||||
return (
|
||||
<Provider value={context}>
|
||||
<AppContent mode={previewSettings?.mode} platform={previewSettings?.platform}>
|
||||
<AppContent>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* always keep route path with param as the last one */}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { AppearanceMode } from '@logto/schemas';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import React, { ReactNode, useEffect, useCallback, useContext } from 'react';
|
||||
import { useDebouncedLoader } from 'use-debounced-loader';
|
||||
|
@ -7,20 +6,16 @@ import LoadingLayer from '@/components/LoadingLayer';
|
|||
import Toast from '@/components/Toast';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
import { Platform } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
mode?: AppearanceMode;
|
||||
platform?: Platform;
|
||||
};
|
||||
|
||||
const AppContent = ({ children, mode, platform: platformOverwrite }: Props) => {
|
||||
const theme = useTheme(mode);
|
||||
const { toast, loading, platform, setPlatform, setToast, experienceSettings } =
|
||||
useContext(PageContext);
|
||||
const AppContent = ({ children }: Props) => {
|
||||
const theme = useTheme();
|
||||
const { toast, loading, platform, setToast, experienceSettings } = useContext(PageContext);
|
||||
const debouncedLoading = useDebouncedLoader(loading);
|
||||
|
||||
// Prevent internal eventListener rebind
|
||||
|
@ -45,13 +40,6 @@ const AppContent = ({ children, mode, platform: platformOverwrite }: Props) => {
|
|||
);
|
||||
}, [experienceSettings]);
|
||||
|
||||
// Set Platform
|
||||
useEffect(() => {
|
||||
if (platformOverwrite) {
|
||||
setPlatform(platformOverwrite);
|
||||
}
|
||||
}, [platformOverwrite, setPlatform]);
|
||||
|
||||
// Set Theme Mode
|
||||
useEffect(() => {
|
||||
document.body.classList.remove(conditionalString(styles.light), conditionalString(styles.dark));
|
||||
|
|
|
@ -5,7 +5,6 @@ import { useState, useCallback, useContext, useEffect } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePreview from '@/hooks/use-preview';
|
||||
|
||||
type UseApi<T extends any[], U> = {
|
||||
result?: U;
|
||||
|
@ -27,7 +26,6 @@ function useApi<Args extends any[], Response>(
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||
const [error, setError] = useState<RequestErrorBody>();
|
||||
const [result, setResult] = useState<Response>();
|
||||
const [isPreview] = usePreview();
|
||||
|
||||
const { setLoading, setToast } = useContext(PageContext);
|
||||
|
||||
|
@ -53,10 +51,6 @@ function useApi<Args extends any[], Response>(
|
|||
|
||||
const run = useCallback(
|
||||
async (...args: Args) => {
|
||||
if (isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setError(undefined);
|
||||
|
@ -72,7 +66,7 @@ function useApi<Args extends any[], Response>(
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[isPreview, api, parseError, setLoading]
|
||||
[api, parseError, setLoading]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { isMobile } from 'react-device-detect';
|
|||
|
||||
import { SignInExperienceSettings, Platform, Theme } from '@/types';
|
||||
|
||||
type Context = {
|
||||
export type Context = {
|
||||
theme: Theme;
|
||||
toast: string;
|
||||
loading: boolean;
|
||||
|
|
|
@ -1,48 +1,75 @@
|
|||
import { Language } from '@logto/phrases';
|
||||
import { AppearanceMode } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import i18next from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Context } from '@/hooks/use-page-context';
|
||||
import initI18n from '@/i18n/init';
|
||||
import { SignInExperienceSettingsResponse } from '@/types';
|
||||
import { parseQueryParameters } from '@/utils';
|
||||
import { parseSignInExperienceSettings } from '@/utils/sign-in-experience';
|
||||
|
||||
type PreviewConfig = {
|
||||
signInExperience: SignInExperienceSettingsResponse;
|
||||
language: Language;
|
||||
mode: AppearanceMode;
|
||||
mode: AppearanceMode.LightMode | AppearanceMode.DarkMode;
|
||||
platform: 'web' | 'mobile';
|
||||
};
|
||||
|
||||
const usePreview = (): [boolean, PreviewConfig?] => {
|
||||
const { preview, config } = parseQueryParameters(window.location.search);
|
||||
const usePreview = (context: Context): [boolean, PreviewConfig?] => {
|
||||
const [previewConfig, setPreviewConfig] = useState<PreviewConfig>();
|
||||
const { setTheme, setExperienceSettings, setPlatform } = context;
|
||||
|
||||
const previewConfig = useMemo(() => {
|
||||
if (!preview || !config) {
|
||||
const { preview } = parseQueryParameters(window.location.search);
|
||||
const isPreview = preview === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
signInExperience: { languageInfo, ...rest },
|
||||
language,
|
||||
mode,
|
||||
platform,
|
||||
} = JSON.parse(decodeURIComponent(config)) as PreviewConfig;
|
||||
// Init i18n
|
||||
void initI18n();
|
||||
|
||||
// Overwrite languageInfo
|
||||
const settings: SignInExperienceSettingsResponse = {
|
||||
...rest,
|
||||
languageInfo: {
|
||||
...languageInfo,
|
||||
fixedLanguage: language,
|
||||
autoDetect: false,
|
||||
},
|
||||
};
|
||||
const previewMessageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { signInExperience: settings, language, mode, platform };
|
||||
} catch {}
|
||||
}, [config, preview]);
|
||||
if (event.data.sender === 'ac_preview') {
|
||||
setPreviewConfig(event.data.config as PreviewConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return [Boolean(preview), previewConfig];
|
||||
window.addEventListener('message', previewMessageHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', previewMessageHandler);
|
||||
};
|
||||
}, [isPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreview || !previewConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { signInExperience, language, mode, platform } = previewConfig;
|
||||
const experienceSettings = parseSignInExperienceSettings(signInExperience);
|
||||
|
||||
void i18next.changeLanguage(language);
|
||||
|
||||
setTheme(mode);
|
||||
setPlatform(platform);
|
||||
setExperienceSettings({
|
||||
...experienceSettings,
|
||||
branding: {
|
||||
...experienceSettings.branding,
|
||||
isDarkModeEnabled: false, // Disable theme mode auto detection on preview
|
||||
},
|
||||
});
|
||||
}, [isPreview, previewConfig, setExperienceSettings, setPlatform, setTheme]);
|
||||
|
||||
return [isPreview, previewConfig];
|
||||
};
|
||||
|
||||
export default usePreview;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { AppearanceMode } from '@logto/schemas';
|
||||
import { useEffect, useContext } from 'react';
|
||||
|
||||
import { Theme } from '@/types';
|
||||
|
@ -8,16 +7,10 @@ import { PageContext } from './use-page-context';
|
|||
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light');
|
||||
|
||||
export default function useTheme(mode: AppearanceMode = AppearanceMode.SyncWithSystem): Theme {
|
||||
export default function useTheme(): Theme {
|
||||
const { experienceSettings, theme, setTheme } = useContext(PageContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== AppearanceMode.SyncWithSystem) {
|
||||
setTheme(mode);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!experienceSettings?.branding.isDarkModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
@ -33,7 +26,7 @@ export default function useTheme(mode: AppearanceMode = AppearanceMode.SyncWithS
|
|||
return () => {
|
||||
darkThemeWatchMedia.removeEventListener('change', changeTheme);
|
||||
};
|
||||
}, [experienceSettings, mode, setTheme]);
|
||||
}, [experienceSettings, setTheme]);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue