diff --git a/packages/console/src/assets/images/blur-preview.svg b/packages/console/src/assets/images/blur-preview.svg new file mode 100644 index 000000000..9d0723549 --- /dev/null +++ b/packages/console/src/assets/images/blur-preview.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/components/CustomUiAssetsUploader/index.module.scss b/packages/console/src/components/CustomUiAssetsUploader/index.module.scss new file mode 100644 index 000000000..34b5b4ae7 --- /dev/null +++ b/packages/console/src/components/CustomUiAssetsUploader/index.module.scss @@ -0,0 +1,62 @@ +@use '@/scss/underscore' as _; + +.placeholder { + display: flex; + align-items: center; + padding: _.unit(5) _.unit(5) _.unit(5) _.unit(4); + border: 1px solid var(--color-border); + border-radius: 12px; + gap: _.unit(4); + position: relative; + overflow: hidden; + + .main { + display: flex; + flex-direction: column; + gap: _.unit(0.5); + flex: 1; + + .name { + font: var(--font-label-2); + color: var(--color-text-primary); + } + + .secondaryInfo { + display: flex; + align-items: center; + gap: _.unit(3); + } + + .info { + font: var(--font-body-3); + color: var(--color-text-secondary); + } + + .error { + font: var(--font-body-3); + color: var(--color-error); + } + } + + .icon { + width: 40px; + height: 40px; + } + + // display a fake progress bar on the bottom with animations + .progressBar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 4px; + background-color: var(--color-primary); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s; + } + + &.hasError { + border-color: var(--color-error); + } +} diff --git a/packages/console/src/components/CustomUiAssetsUploader/index.tsx b/packages/console/src/components/CustomUiAssetsUploader/index.tsx new file mode 100644 index 000000000..79b240d81 --- /dev/null +++ b/packages/console/src/components/CustomUiAssetsUploader/index.tsx @@ -0,0 +1,89 @@ +import { type CustomUiAssets, maxUploadFileSize, type AllowedUploadMimeType } from '@logto/schemas'; +import { format } from 'date-fns/fp'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import DeleteIcon from '@/assets/icons/delete.svg'; +import IconButton from '@/ds-components/IconButton'; +import FileUploader from '@/ds-components/Uploader/FileUploader'; +import { formatBytes } from '@/utils/uploader'; + +import FileIcon from '../FileIcon'; + +import * as styles from './index.module.scss'; + +type Props = { + readonly value?: CustomUiAssets; + readonly onChange: (value: CustomUiAssets) => void; +}; + +const allowedMimeTypes: AllowedUploadMimeType[] = ['application/zip']; + +function CustomUiAssetsUploader({ value, onChange }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const [file, setFile] = useState(); + const [error, setError] = useState(); + const showUploader = !value?.id && !file && !error; + + const onComplete = useCallback( + (id: string) => { + setFile(undefined); + onChange({ id, createdAt: Date.now() }); + }, + [onChange] + ); + + const onErrorChange = useCallback( + (errorMessage?: string, files?: File[]) => { + if (errorMessage) { + setError(errorMessage); + } + if (files?.length) { + setFile(files[0]); + } + }, + [setError, setFile] + ); + + if (showUploader) { + return ( + + allowedMimeTypes={allowedMimeTypes} + maxSize={maxUploadFileSize} + uploadUrl="api/sign-in-exp/default/custom-ui-assets" + onCompleted={({ customUiAssetId }) => { + onComplete(customUiAssetId); + }} + onUploadErrorChange={onErrorChange} + /> + ); + } + + return ( +
+ +
+
{file?.name ?? t('sign_in_exp.custom_ui.title')}
+
+ {!!value?.createdAt && ( + {format('yyyy/MM/dd HH:mm')(value.createdAt)} + )} + {file && {formatBytes(file.size)}} + {error && {error}} +
+
+ { + setFile(undefined); + setError(undefined); + onChange({ id: '', createdAt: 0 }); + }} + > + + + {file &&
} +
+ ); +} + +export default CustomUiAssetsUploader; diff --git a/packages/console/src/components/FileIcon/index.tsx b/packages/console/src/components/FileIcon/index.tsx new file mode 100644 index 000000000..bab200d1d --- /dev/null +++ b/packages/console/src/components/FileIcon/index.tsx @@ -0,0 +1,20 @@ +import { Theme } from '@logto/schemas'; +import { type ReactNode } from 'react'; + +import FileIconDark from '@/assets/icons/file-icon-dark.svg'; +import FileIconLight from '@/assets/icons/file-icon.svg'; +import useTheme from '@/hooks/use-theme'; + +const themeToRoleIcon = Object.freeze({ + [Theme.Light]: , + [Theme.Dark]: , +} satisfies Record); + +/** Render a role icon according to the current theme. */ +const FileIcon = () => { + const theme = useTheme(); + + return themeToRoleIcon[theme]; +}; + +export default FileIcon; diff --git a/packages/console/src/components/ImageInputs/index.tsx b/packages/console/src/components/ImageInputs/index.tsx index a9f8d6f5e..dcabc41d3 100644 --- a/packages/console/src/components/ImageInputs/index.tsx +++ b/packages/console/src/components/ImageInputs/index.tsx @@ -137,7 +137,9 @@ function ImageInputs({ actionDescription={t(`sign_in_exp.branding.with_${field.theme}`, { value: t(`sign_in_exp.branding_uploads.${field.type}.title`), })} - onCompleted={onChange} + onCompleted={({ url }) => { + onChange(url); + }} // Noop fallback should not be necessary, but for TypeScript to be happy onUploadErrorChange={uploadErrorChangeHandlers[field.name] ?? noop} onDelete={() => { diff --git a/packages/console/src/components/SignInExperiencePreview/index.module.scss b/packages/console/src/components/SignInExperiencePreview/index.module.scss index 7f9457496..4948109aa 100644 --- a/packages/console/src/components/SignInExperiencePreview/index.module.scss +++ b/packages/console/src/components/SignInExperiencePreview/index.module.scss @@ -85,4 +85,28 @@ } } } + + &.disabled { + background: url('raw:../../assets/images/blur-preview.svg') 0 0 / 100% auto no-repeat; + } + + .placeholder { + width: 100%; + height: calc(_screenSize.$web-height + _.unit(20)); + padding: _.unit(10); + backdrop-filter: blur(25px); + display: flex; + flex-direction: column; + color: var(--color-static-white); + + .title { + font: var(--font-label-2); + } + + .description { + margin-top: _.unit(1.5); + font: var(--font-body-2); + white-space: pre-wrap; + } + } } diff --git a/packages/console/src/components/SignInExperiencePreview/index.tsx b/packages/console/src/components/SignInExperiencePreview/index.tsx index ca0a61ac8..ef6ff41b2 100644 --- a/packages/console/src/components/SignInExperiencePreview/index.tsx +++ b/packages/console/src/components/SignInExperiencePreview/index.tsx @@ -4,7 +4,15 @@ import type { ConnectorMetadata, SignInExperience, ConnectorResponse } from '@lo import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; import { format } from 'date-fns'; -import { useContext, useRef, useMemo, useCallback, useEffect, useState } from 'react'; +import { + useContext, + useRef, + useMemo, + useCallback, + useEffect, + useState, + type ReactNode, +} from 'react'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; @@ -28,6 +36,16 @@ type Props = { * the `AppDataContext` will be used. */ readonly endpoint?: URL; + /** + * Whether the preview is disabled. If `true`, the preview will be disabled and a placeholder will + * be shown instead. Defaults to `false`. + */ + // eslint-disable-next-line react/boolean-prop-naming + readonly disabled?: boolean; + /** + * The placeholder to show when the preview is disabled. + */ + readonly disabledPlaceholder?: ReactNode; }; function SignInExperiencePreview({ @@ -36,6 +54,8 @@ function SignInExperiencePreview({ language = 'en', signInExperience, endpoint: endpointInput, + disabled = false, + disabledPlaceholder, }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -97,6 +117,10 @@ function SignInExperiencePreview({ }, []); useEffect(() => { + if (disabled) { + setIframeLoaded(false); + return; + } const iframe = previewRef.current; iframe?.addEventListener('load', iframeOnLoadEventHandler); @@ -104,7 +128,7 @@ function SignInExperiencePreview({ return () => { iframe?.removeEventListener('load', iframeOnLoadEventHandler); }; - }, [iframeLoaded, iframeOnLoadEventHandler]); + }, [iframeLoaded, disabled, iframeOnLoadEventHandler]); useEffect(() => { if (!iframeLoaded) { @@ -122,7 +146,8 @@ function SignInExperiencePreview({
-
-
- {platform !== PreviewPlatform.DesktopWeb && ( -
-
{format(Date.now(), 'HH:mm')}
- -
- )} -