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:
parent
f7faa544b1
commit
d01152895c
35 changed files with 596 additions and 356 deletions
|
@ -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;
|
|
@ -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;
|
|
@ -59,12 +59,3 @@
|
|||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
}
|
124
packages/console/src/components/Uploader/FileUploader/index.tsx
Normal file
124
packages/console/src/components/Uploader/FileUploader/index.tsx
Normal 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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
3
packages/console/src/components/Uploader/index.tsx
Normal file
3
packages/console/src/components/Uploader/index.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as FileUploader } from './FileUploader';
|
||||
export { default as ImageUploader } from './ImageUploader';
|
||||
export { default as ImageUploaderField } from './ImageUploaderField';
|
16
packages/console/src/consts/user-assets.ts
Normal file
16
packages/console/src/consts/user-assets.ts
Normal 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);
|
|
@ -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} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -61,3 +61,7 @@
|
|||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.imageFieldHeadline {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
|
|
8
packages/console/src/utils/uploader.ts
Normal file
8
packages/console/src/utils/uploader.ts
Normal 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());
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue