0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(console): use image uploader in branding form (#3410)

This commit is contained in:
Xiao Yijun 2023-03-15 13:22:10 +08:00 committed by GitHub
parent f7faa544b1
commit d01152895c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 596 additions and 356 deletions

View file

@ -1,120 +0,0 @@
import type { AllowedUploadMimeType, UserAssets } from '@logto/schemas';
import { maxUploadFileSize } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import UploaderIcon from '@/assets/images/upload.svg';
import useApi from '@/hooks/use-api';
import { Ring } from '../Spinner';
import * as styles from './index.module.scss';
const allowedFileCount = 1;
type Props = {
maxSize: number; // In bytes
allowedMimeTypes: AllowedUploadMimeType[];
limitDescription: string;
onCompleted: (fileUrl: string) => void;
};
const FileUploader = ({ maxSize, allowedMimeTypes, limitDescription, onCompleted }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isUploading, setIsUploading] = useState(false);
const [uploaderError, setUploaderError] = useState<string>();
const hasError = Boolean(uploaderError);
const api = useApi();
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
setUploaderError(undefined);
if (acceptedFiles.length > allowedFileCount) {
setUploaderError(t('components.uploader.error_file_count', { count: allowedFileCount }));
return;
}
const selectedFile = acceptedFiles[0];
if (!selectedFile) {
return;
}
if (!allowedMimeTypes.map(String).includes(selectedFile.type)) {
const supportedFileTypes = allowedMimeTypes.map((type) =>
type.split('/')[1]?.toUpperCase()
);
setUploaderError(t('components.uploader.error_file_type', { types: supportedFileTypes }));
return;
}
const fileSizeLimit = Math.min(maxSize, maxUploadFileSize);
if (selectedFile.size > fileSizeLimit) {
setUploaderError(t('components.uploader.error_file_size', { count: fileSizeLimit / 1024 }));
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
try {
setIsUploading(true);
const { url } = await api.post('api/user-assets', { body: formData }).json<UserAssets>();
onCompleted(url);
} catch {
setUploaderError(t('components.uploader.error_upload'));
} finally {
setIsUploading(false);
}
},
[allowedMimeTypes, api, maxSize, onCompleted, t]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: isUploading,
});
return (
<div>
<div
{...getRootProps()}
className={classNames(
styles.uploader,
uploaderError && styles.uploaderError,
isDragActive && styles.dragActive
)}
>
<input {...getInputProps()} />
<div className={styles.placeholder}>
{isUploading ? (
<>
<Ring className={styles.uploadingIcon} />
<div className={styles.actionDescription}>{t('components.uploader.uploading')}</div>
</>
) : (
<>
<UploaderIcon className={styles.icon} />
<div className={styles.actionDescription}>
{t('components.uploader.action_description')}
</div>
</>
)}
</div>
</div>
<div className={classNames(styles.description, hasError && styles.error)}>
{hasError ? uploaderError : limitDescription}
</div>
</div>
);
};
export default FileUploader;

View file

@ -1,50 +0,0 @@
import { useTranslation } from 'react-i18next';
import Delete from '@/assets/images/delete.svg';
import FileUploader from '../FileUploader';
import IconButton from '../IconButton';
import * as styles from './index.module.scss';
type Props = {
name: string;
value: string;
onChange: (value: string) => void;
};
const maxImageSize = 500 * 1024; // 500 KB
const ImageUploader = ({ name, value, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const limitDescription = t('components.uploader.image_limit');
if (!value) {
return (
<FileUploader
allowedMimeTypes={['image/jpeg', 'image/png', 'image/gif', 'image/webp']}
maxSize={maxImageSize}
limitDescription={limitDescription}
onCompleted={onChange}
/>
);
}
return (
<div>
<div className={styles.imageUploader}>
<img alt={name} src={value} />
<IconButton
className={styles.delete}
onClick={() => {
onChange('');
}}
>
<Delete />
</IconButton>
</div>
<div className={styles.limit}>{limitDescription}</div>
</div>
);
};
export default ImageUploader;

View file

@ -59,12 +59,3 @@
}
}
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(2);
}
.error {
color: var(--color-error);
}

View file

@ -0,0 +1,124 @@
import type { AllowedUploadMimeType, UserAssets } from '@logto/schemas';
import { maxUploadFileSize } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import UploaderIcon from '@/assets/images/upload.svg';
import useApi from '@/hooks/use-api';
import { convertToFileExtensionArray } from '@/utils/uploader';
import { Ring } from '../../Spinner';
import * as styles from './index.module.scss';
export type Props = {
maxSize: number; // In bytes
allowedMimeTypes: AllowedUploadMimeType[];
actionDescription?: string;
onCompleted: (fileUrl: string) => void;
onUploadErrorChange: (errorMessage?: string) => void;
};
const FileUploader = ({
maxSize,
allowedMimeTypes,
actionDescription,
onCompleted,
onUploadErrorChange,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>();
useEffect(() => {
onUploadErrorChange(uploadError);
return () => {
onUploadErrorChange(undefined);
};
}, [onUploadErrorChange, uploadError]);
const api = useApi();
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
setUploadError(undefined);
const selectedFile = acceptedFiles[0];
if (!selectedFile) {
return;
}
if (!allowedMimeTypes.map(String).includes(selectedFile.type)) {
setUploadError(
t('components.uploader.error_file_type', {
extensions: convertToFileExtensionArray(allowedMimeTypes),
})
);
return;
}
const fileSizeLimit = Math.min(maxSize, maxUploadFileSize);
if (selectedFile.size > fileSizeLimit) {
setUploadError(t('components.uploader.error_file_size', { size: fileSizeLimit / 1024 }));
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
try {
setIsUploading(true);
const { url } = await api.post('api/user-assets', { body: formData }).json<UserAssets>();
onCompleted(url);
} catch {
setUploadError(t('components.uploader.error_upload'));
} finally {
setIsUploading(false);
}
},
[allowedMimeTypes, api, maxSize, onCompleted, t]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: isUploading,
multiple: false,
});
return (
<div
{...getRootProps()}
className={classNames(
styles.uploader,
Boolean(uploadError) && styles.uploaderError,
isDragActive && styles.dragActive
)}
>
<input {...getInputProps()} />
<div className={styles.placeholder}>
{isUploading ? (
<>
<Ring className={styles.uploadingIcon} />
<div className={styles.actionDescription}>{t('components.uploader.uploading')}</div>
</>
) : (
<>
<UploaderIcon className={styles.icon} />
<div className={styles.actionDescription}>
{actionDescription ?? t('components.uploader.action_description')}
</div>
</>
)}
</div>
</div>
);
};
export default FileUploader;

View file

@ -3,7 +3,7 @@
.imageUploader {
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: _.unit(6) 0;
padding: _.unit(6) _.unit(2);
display: flex;
flex-direction: column;
align-items: center;
@ -17,12 +17,7 @@
> img {
height: 40px;
object-fit: cover;
max-width: 100%;
object-fit: contain;
}
}
.limit {
margin-top: _.unit(2);
font: var(--font-body-2);
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,43 @@
import type { AllowedUploadMimeType } from '@logto/schemas';
import Delete from '@/assets/images/delete.svg';
import IconButton from '../../IconButton';
import FileUploader from '../FileUploader';
import type { Props as FileUploaderProps } from '../FileUploader';
import * as styles from './index.module.scss';
export const maxImageSizeLimit = 500 * 1024; // 500 KB
export const allowedImageMimeTypes: AllowedUploadMimeType[] = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
export type Props = Omit<FileUploaderProps, 'maxSize' | 'allowedMimeTypes'> & {
name: string;
value: string;
onDelete: () => void;
};
const ImageUploader = ({ name, value, onDelete, ...rest }: Props) =>
value ? (
<div className={styles.imageUploader}>
<img alt={name} src={value} />
<IconButton
className={styles.delete}
onClick={() => {
onDelete();
}}
>
<Delete />
</IconButton>
</div>
) : (
<FileUploader allowedMimeTypes={allowedImageMimeTypes} maxSize={maxImageSizeLimit} {...rest} />
);
export default ImageUploader;

View file

@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;
.description {
margin-top: _.unit(2);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.error {
color: var(--color-error);
}

View file

@ -0,0 +1,42 @@
import classNames from 'classnames';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { convertToFileExtensionArray } from '@/utils/uploader';
import ImageUploader, { maxImageSizeLimit, allowedImageMimeTypes } from '../ImageUploader';
import type { Props as ImageUploaderProps } from '../ImageUploader';
import * as styles from './index.module.scss';
type Props = Pick<ImageUploaderProps, 'name' | 'value' | 'actionDescription'> & {
onChange: (value: string) => void;
};
const ImageUploaderField = ({ onChange, ...rest }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const limitDescription = t('components.uploader.image_limit', {
size: maxImageSizeLimit / 1024,
extensions: convertToFileExtensionArray(allowedImageMimeTypes),
});
const [uploadError, setUploadError] = useState<string>();
return (
<div>
<ImageUploader
onCompleted={onChange}
onUploadErrorChange={setUploadError}
onDelete={() => {
onChange('');
}}
{...rest}
/>
<div className={classNames(styles.description, Boolean(uploadError) && styles.error)}>
{uploadError ?? limitDescription}
</div>
</div>
);
};
export default ImageUploaderField;

View file

@ -0,0 +1,3 @@
export { default as FileUploader } from './FileUploader';
export { default as ImageUploader } from './ImageUploader';
export { default as ImageUploaderField } from './ImageUploaderField';

View file

@ -0,0 +1,16 @@
import type { AllowedUploadMimeType } from '@logto/schemas';
type MimeTypeToFileExtensionMappings = {
[key in AllowedUploadMimeType]: readonly string[];
};
export const mimeTypeToFileExtensionMappings: MimeTypeToFileExtensionMappings = Object.freeze({
'image/jpeg': ['jpeg', 'jpg'],
'image/png': ['png'],
'image/gif': ['gif'],
'image/vnd.microsoft.icon': ['ico'],
'image/svg+xml': ['svg'],
'image/tiff': ['tif', 'tiff'],
'image/webp': ['webp'],
'image/bmp': ['bmp'],
} as const);

View file

@ -11,9 +11,9 @@ import Tools from '@/assets/images/tools.svg';
import Button from '@/components/Button';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import ImageUploader from '@/components/ImageUploader';
import OverlayScrollbar from '@/components/OverlayScrollbar';
import TextInput from '@/components/TextInput';
import { ImageUploaderField } from '@/components/Uploader';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useUserAssetsService from '@/hooks/use-user-assets-service';
@ -112,7 +112,7 @@ const SignInExperience = () => {
name="logo"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploader name={name} value={value ?? ''} onChange={onChange} />
<ImageUploaderField name={name} value={value ?? ''} onChange={onChange} />
)}
/>
) : (

View file

@ -18,7 +18,6 @@ import * as modalStyles from '@/scss/modal.module.scss';
import usePreviewConfigs from '../../hooks/use-preview-configs';
import BrandingForm from '../../tabs/Branding/BrandingForm';
import ColorForm from '../../tabs/Branding/ColorForm';
import LanguagesForm from '../../tabs/Others/LanguagesForm';
import TermsForm from '../../tabs/Others/TermsForm';
import type { SignInExperienceForm } from '../../types';
@ -116,7 +115,6 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
)}
<div className={styles.main}>
<div className={styles.form}>
<ColorForm />
<BrandingForm />
<TermsForm />
<LanguagesForm />

View file

@ -1,59 +1,163 @@
import { useFormContext } from 'react-hook-form';
import { generateDarkColor } from '@logto/core-kit';
import { useMemo, useCallback, useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import Card from '@/components/Card';
import ColorPicker from '@/components/ColorPicker';
import DangerousRaw from '@/components/DangerousRaw';
import FormField from '@/components/FormField';
import Switch from '@/components/Switch';
import TextInput from '@/components/TextInput';
import { ImageUploaderField } from '@/components/Uploader';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
import LogoAndFaviconUploader from './components/LogoAndFaviconUploader';
const BrandingForm = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { isReady: isUserAssetsServiceReady } = useUserAssetsService();
const {
watch,
register,
formState: { errors },
setValue,
control,
formState: { errors, isDirty },
} = useFormContext<SignInExperienceForm>();
const isDarkModeEnabled = watch('color.isDarkModeEnabled');
const primaryColor = watch('color.primaryColor');
const darkPrimaryColor = watch('color.darkPrimaryColor');
const calculatedDarkPrimaryColor = useMemo(() => {
return generateDarkColor(primaryColor);
}, [primaryColor]);
const handleResetColor = useCallback(() => {
setValue('color.darkPrimaryColor', calculatedDarkPrimaryColor);
}, [calculatedDarkPrimaryColor, setValue]);
useEffect(() => {
if (!isDirty) {
return;
}
// If it's enabled, the original dark mode color won't change, users need to click "reset".
if (!isDarkModeEnabled) {
handleResetColor();
}
}, [handleResetColor, isDarkModeEnabled, isDirty]);
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.branding.title')}</div>
<FormField title="sign_in_exp.branding.favicon">
<TextInput
{...register('branding.favicon', {
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.favicon)}
errorMessage={errors.branding?.favicon?.message}
placeholder={t('sign_in_exp.branding.favicon')}
<FormField title="sign_in_exp.color.primary_color">
<Controller
name="color.primaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField title="sign_in_exp.branding.logo_image_url">
<TextInput
{...register('branding.logoUrl', {
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.logoUrl)}
errorMessage={errors.branding?.logoUrl?.message}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
{isUserAssetsServiceReady ? (
<FormField
title={
<DangerousRaw>
{t('sign_in_exp.branding.logo_image')}
{' & '}
{t('sign_in_exp.branding.favicon')}
</DangerousRaw>
}
headlineClassName={styles.imageFieldHeadline}
>
<LogoAndFaviconUploader />
</FormField>
) : (
<>
<FormField title="sign_in_exp.branding.logo_image_url">
<TextInput
{...register('branding.logoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.logoUrl)}
errorMessage={errors.branding?.logoUrl?.message}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
/>
</FormField>
<FormField title="sign_in_exp.branding.favicon">
<TextInput
{...register('branding.favicon', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.favicon)}
errorMessage={errors.branding?.favicon?.message}
placeholder={t('sign_in_exp.branding.favicon')}
/>
</FormField>
</>
)}
<FormField title="sign_in_exp.color.dark_mode">
<Switch
label={t('sign_in_exp.color.dark_mode_description')}
{...register('color.isDarkModeEnabled')}
/>
</FormField>
{isDarkModeEnabled && (
<FormField title="sign_in_exp.branding.dark_logo_image_url">
<TextInput
{...register('branding.darkLogoUrl', {
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.darkLogoUrl)}
errorMessage={errors.branding?.darkLogoUrl?.message}
placeholder={t('sign_in_exp.branding.dark_logo_image_url_placeholder')}
/>
</FormField>
<>
<FormField title="sign_in_exp.color.dark_primary_color">
<Controller
name="color.darkPrimaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
{calculatedDarkPrimaryColor !== darkPrimaryColor && (
<div className={styles.darkModeTip}>
{t('sign_in_exp.color.dark_mode_reset_tip')}
<Button
type="text"
size="small"
title="sign_in_exp.color.reset"
onClick={handleResetColor}
/>
</div>
)}
</FormField>
{isUserAssetsServiceReady ? (
<FormField
title="sign_in_exp.branding.dark_logo_image"
headlineClassName={styles.imageFieldHeadline}
>
<Controller
name="branding.darkLogoUrl"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploaderField name={name} value={value ?? ''} onChange={onChange} />
)}
/>
</FormField>
) : (
<FormField title="sign_in_exp.branding.dark_logo_image_url">
<TextInput
{...register('branding.darkLogoUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.branding?.darkLogoUrl)}
errorMessage={errors.branding?.darkLogoUrl?.message}
placeholder={t('sign_in_exp.branding.dark_logo_image_url_placeholder')}
/>
</FormField>
)}
</>
)}
</Card>
);

View file

@ -1,94 +0,0 @@
import { generateDarkColor } from '@logto/core-kit';
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import Card from '@/components/Card';
import ColorPicker from '@/components/ColorPicker';
import FormField from '@/components/FormField';
import Switch from '@/components/Switch';
import type { SignInExperienceForm } from '../../types';
import * as styles from '../index.module.scss';
const ColorForm = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
watch,
register,
control,
setValue,
formState: { isDirty },
} = useFormContext<SignInExperienceForm>();
const isDarkModeEnabled = watch('color.isDarkModeEnabled');
const primaryColor = watch('color.primaryColor');
const darkPrimaryColor = watch('color.darkPrimaryColor');
const calculatedDarkPrimaryColor = useMemo(() => {
return generateDarkColor(primaryColor);
}, [primaryColor]);
const handleResetColor = useCallback(() => {
setValue('color.darkPrimaryColor', calculatedDarkPrimaryColor);
}, [calculatedDarkPrimaryColor, setValue]);
useEffect(() => {
if (!isDirty) {
return;
}
// If it's enabled, the original dark mode color won't change, users need to click "reset".
if (!isDarkModeEnabled) {
handleResetColor();
}
}, [handleResetColor, isDarkModeEnabled, isDirty, primaryColor, setValue]);
return (
<Card>
<div className={styles.title}>{t('sign_in_exp.color.title')}</div>
<FormField title="sign_in_exp.color.primary_color">
<Controller
name="color.primaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField title="sign_in_exp.color.dark_mode">
<Switch
label={t('sign_in_exp.color.dark_mode_description')}
{...register('color.isDarkModeEnabled')}
/>
</FormField>
{isDarkModeEnabled && (
<>
<FormField title="sign_in_exp.color.dark_primary_color">
<Controller
name="color.darkPrimaryColor"
control={control}
render={({ field: { onChange, value } }) => (
<ColorPicker value={value} onChange={onChange} />
)}
/>
</FormField>
{calculatedDarkPrimaryColor !== darkPrimaryColor && (
<div className={styles.darkModeTip}>
{t('sign_in_exp.color.dark_mode_reset_tip')}
<Button
type="text"
size="small"
title="sign_in_exp.color.reset"
onClick={handleResetColor}
/>
</div>
)}
</>
)}
</Card>
);
};
export default ColorForm;

View file

@ -0,0 +1,29 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
}
.uploader {
display: flex;
gap: _.unit(2);
.logoUploader {
flex: 2 0;
}
.faviconUploader {
flex: 1;
}
}
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1);
}
.error {
color: var(--color-error);
}

View file

@ -0,0 +1,82 @@
import classNames from 'classnames';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ImageUploader, {
allowedImageMimeTypes,
maxImageSizeLimit,
} from '@/components/Uploader/ImageUploader';
import type { SignInExperienceForm } from '@/pages/SignInExperience/types';
import { convertToFileExtensionArray } from '@/utils/uploader';
import * as styles from './index.module.scss';
const LogoAndFaviconUploader = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [uploadLogoError, setUploadLogoError] = useState<string>();
const [uploadFaviconError, setUploadFaviconError] = useState<string>();
const { control } = useFormContext<SignInExperienceForm>();
return (
<div className={styles.container}>
<div className={styles.uploader}>
<div className={styles.logoUploader}>
<Controller
control={control}
name="branding.logoUrl"
render={({ field: { onChange, value, name } }) => (
<ImageUploader
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.upload_logo_image_description')}
onCompleted={onChange}
onUploadErrorChange={setUploadLogoError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
</div>
<div className={styles.faviconUploader}>
<Controller
control={control}
name="branding.favicon"
render={({ field: { onChange, value, name } }) => (
<ImageUploader
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.upload_favicon_description')}
onCompleted={onChange}
onUploadErrorChange={setUploadFaviconError}
onDelete={() => {
onChange('');
}}
/>
)}
/>
</div>
</div>
{uploadLogoError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.logo_image_error', { error: uploadLogoError })}
</div>
)}
{uploadFaviconError && (
<div className={classNames(styles.description, styles.error)}>
{t('sign_in_exp.branding.favicon_error', { error: uploadFaviconError })}
</div>
)}
<div className={styles.description}>
{t('components.uploader.image_limit', {
size: maxImageSizeLimit / 1024,
extensions: convertToFileExtensionArray(allowedImageMimeTypes),
})}
</div>
</div>
);
};
export default LogoAndFaviconUploader;

View file

@ -1,7 +1,6 @@
import TabWrapper from '../../components/TabWrapper';
import * as styles from '../index.module.scss';
import BrandingForm from './BrandingForm';
import ColorForm from './ColorForm';
import CustomCssForm from './CustomCssForm';
type Props = {
@ -10,7 +9,6 @@ type Props = {
const Branding = ({ isActive }: Props) => (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<ColorForm />
<BrandingForm />
<CustomCssForm />
</TabWrapper>

View file

@ -61,3 +61,7 @@
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.imageFieldHeadline {
margin-bottom: _.unit(2);
}

View file

@ -0,0 +1,8 @@
import type { AllowedUploadMimeType } from '@logto/schemas';
import { mimeTypeToFileExtensionMappings } from '@/consts/user-assets';
export const convertToFileExtensionArray = (mimeTypes: AllowedUploadMimeType[]) =>
mimeTypes
.flatMap((type) => mimeTypeToFileExtensionMappings[type])
.map((extension) => extension.toUpperCase());

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -89,6 +89,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://dein.cdn.domain/logo.png',
dark_logo_image_url: 'App logo URL (Dunkler Modus)',
dark_logo_image_url_placeholder: 'https://dein.cdn.domain/logo-dark.png',
logo_image: 'App logo',
dark_logo_image: 'App logo (Dunkler Modus)',
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -32,6 +32,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'App logo image URL (Dark)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo',
dark_logo_image: 'App logo (Dark)',
logo_action_description: 'App Logo to display in UI interface',
favicon_action_description: 'Browser Favicon',
logo_image_error: 'App logo: {{error}}',
favicon_error: 'Favicon: {{error}}',
upload_logo_image_description: 'App Logo to display in UI interface',
upload_favicon_description: 'Browser Favicon',
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -34,6 +34,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://votre.domaine.cdn/logo.png',
dark_logo_image_url: "URL de l'image du logo de l'application (Sombre)",
dark_logo_image_url_placeholder: 'https://votre.domaine.cdn/logo-dark.png',
logo_image: 'App logo', // UNTRANSLATED
dark_logo_image: 'App logo (Dark)', // UNTRANSLATED
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -30,6 +30,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: '앱 로고 이미지 URL (다크 모드)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo', // UNTRANSLATED
dark_logo_image: 'App logo (Dark)', // UNTRANSLATED
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -33,6 +33,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL da imagem do logotipo do aplicativo (Escuro)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo', // UNTRANSLATED
dark_logo_image: 'App logo (Dark)', // UNTRANSLATED
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -32,6 +32,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'URL do logotipo da app (tema escuro)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo', // UNTRANSLATED
dark_logo_image: 'App logo (Dark)', // UNTRANSLATED
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -33,6 +33,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Uygulama logosu resim URLi (Koyu)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo', // UNTRANSLATED
dark_logo_image: 'App logo (Dark)', // UNTRANSLATED
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED

View file

@ -2,13 +2,12 @@ const components = {
uploader: {
action_description: 'Drag and drop or browse', // UNTRANSLATED
uploading: 'Uploading...', // UNTRANSLATED
image_limit: 'Upload image under 500KB, JPG, PNG, GIF, WEBP only.', // UNTRANSLATED
image_limit:
'Upload image under {{size, number}}KB, {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_upload: 'Something went wrong. File upload failed.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{count, number}}KB.', // UNTRANSLATED
error_file_size: 'File size is too large. Please upload a file under {{size, number}}KB.', // UNTRANSLATED
error_file_type:
'File type is not supported. {{types, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
error_file_count_one: 'You can only upload {{count, number}} file.', // UNTRANSLATED
error_file_count_other: 'You can only upload {{count, number}} files.', // UNTRANSLATED
'File type is not supported. {{extensions, list(style: narrow; type: conjunction;)}} only.', // UNTRANSLATED
},
};

View file

@ -31,6 +31,14 @@ const sign_in_exp = {
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'Logo 图片 URL (深色)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'Logo 图片',
dark_logo_image: 'Logo 图片(深色)',
logo_action_description: 'App Logo to display in UI interface', // UNTRANSLATED
favicon_action_description: 'Browser Favicon', // UNTRANSLATED
logo_image_error: 'App logo: {{error}}', // UNTRANSLATED
favicon_error: 'Favicon: {{error}}', // UNTRANSLATED
upload_logo_image_description: 'App Logo to display in UI interface', // UNTRANSLATED
upload_favicon_description: 'Browser Favicon', // UNTRANSLATED
},
custom_css: {
title: 'CUSTOM CSS', // UNTRANSLATED