diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index aebf69886..8f98ab1d5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -78,7 +78,16 @@ "types": { "photo": "photo" }, - "limit": "no more than {{maxFileMBSize}}mb" + "error": { + "weight": "The file size exceeds {{maxFileKBSize}}kb", + "size": "Upload error: Image must be square", + "crop": "You can crop the image here:", + "compress": "You can compress the image here:" + }, + "uploaded": "Image added successfully", + "limit": "no more than {{maxFileKBSize}}kb", + "size": "Recommended size — 800х800 px", + "square": "We accept only square images." }, "validation": { "required": "This field is required to be completed", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 2d838fbd8..17a81ea98 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -79,7 +79,16 @@ "types": { "photo": "фото" }, - "limit": "не более {{maxFileMBSize}}мб" + "error": { + "weight": "Размер файла превышает {{maxFileKBSize}}КБ", + "size": "Ошибка загрузки: изображение должно быть квадратным", + "crop": "Вы можете обрезать изображение здесь:", + "compress": "Вы можете сжать изображение здесь:" + }, + "uploaded": "Изображение добавлено успешно", + "limit": "максимальный размер файла {{maxFileKBSize}}КБ", + "size": "Рекомендуемый размер — 800х800 px", + "square": "Принимаем только квадратные картинки" }, "validation": { "required": "Это поле обязательно для заполнения", diff --git a/src/shared/assets/icons/checkSquare.svg b/src/shared/assets/icons/checkSquare.svg new file mode 100644 index 000000000..03cdca9a3 --- /dev/null +++ b/src/shared/assets/icons/checkSquare.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/config/i18n/i18nTranslations.ts b/src/shared/config/i18n/i18nTranslations.ts index 705d27e0f..b8612be9d 100644 --- a/src/shared/config/i18n/i18nTranslations.ts +++ b/src/shared/config/i18n/i18nTranslations.ts @@ -59,7 +59,14 @@ export enum Translation { FILE_LOADER_LINK = 'file.loader.link', FILE_LOADER_TEXT = 'file.loader.text', FILE_LOADER_TYPES_PHOTO = 'file.loader.types.photo', + FILE_LOADER_UPLOADED = 'file.loader.uploaded', FILE_LOADER_LIMIT = 'file.loader.limit', + FILE_LOADER_SIZE = 'file.loader.size', + FILE_LOADER_SQUARE = 'file.loader.square', + FILE_LOADER_ERROR_WEIGHT = 'file.loader.error.weight', + FILE_LOADER_ERROR_SIZE = 'file.loader.error.size', + FILE_LOADER_ERROR_CROP = 'file.loader.error.crop', + FILE_LOADER_ERROR_COMPRESS = 'file.loader.error.compress', SETTINGS = 'settings', CRUMBS_PROFILE = 'crumbs.profile', diff --git a/src/shared/ui/FileLoader/FileLoader.module.css b/src/shared/ui/FileLoader/FileLoader.module.css index b74cc18a3..8b7725ece 100644 --- a/src/shared/ui/FileLoader/FileLoader.module.css +++ b/src/shared/ui/FileLoader/FileLoader.module.css @@ -26,3 +26,44 @@ cursor: not-allowed; opacity: 0.6; } + +.file-upload-container.error{ + border: 2px dashed var(--color-red-600); +} + +.file-upload-container.is-uploaded { + border: 2px dashed var(--color-green-900); +} + +.file-link{ + color: var(--color-purple-700); + text-decoration: underline; + cursor: pointer; + font-size: var( --font-size-h-xxs); +} + +.file-link:hover, +.file-link:focus { + text-decoration: none; +} + + +.warning-icon{ + flex-shrink: 0; + width: 28px; + height: 25px; +} + +.warning-icon path { + fill: var(--color-red-600); +} + +.check-square-icon{ + flex-shrink: 0; + width: 28px; + height: 28px; +} + +.check-square-icon path { + fill: var(--color-green-900); +} \ No newline at end of file diff --git a/src/shared/ui/FileLoader/FileLoader.tsx b/src/shared/ui/FileLoader/FileLoader.tsx index aa1bd6f1d..c1e070a32 100644 --- a/src/shared/ui/FileLoader/FileLoader.tsx +++ b/src/shared/ui/FileLoader/FileLoader.tsx @@ -4,6 +4,8 @@ import classNames from 'classnames'; import { DragEvent, RefObject, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import CheckSquare from '@/shared/assets/icons/checkSquare.svg'; +import Warning from '@/shared/assets/icons/warning.svg'; import Gallery from '@/shared/assets/images/Gallery.avif'; import { i18Namespace } from '@/shared/config/i18n'; import { Translation } from '@/shared/config/i18n/i18nTranslations'; @@ -17,7 +19,7 @@ import { Accept, Extension } from './types'; export interface FileLoaderProps { accept: Accept; multiply?: boolean; - maxFileMBSize?: number; + maxFileKBSize?: number; fileTypeText: string; className?: string; extensionsText: Extension; @@ -30,7 +32,7 @@ export const FileLoader = ({ className, accept, fileTypeText, - maxFileMBSize, + maxFileKBSize, extensionsText, multiply = false, onChange, @@ -40,6 +42,8 @@ export const FileLoader = ({ const uploaderRef: RefObject = useRef(null); const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + const [isUploaded, setIsUploaded] = useState(false); const { t } = useTranslation(i18Namespace.translation); @@ -50,19 +54,55 @@ export const FileLoader = ({ input.value = ''; }; - const handleChange = () => { + const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + img.onload = () => resolve({ width: img.width, height: img.height }); + img.onerror = reject; + img.src = e.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + const handleChange = async () => { + setIsUploaded(false); + setError(null); if (disabled) return null; if (uploaderRef.current) { const refFiles = uploaderRef?.current.files; if (refFiles && refFiles.length > 0) { const file = Array.from(refFiles); + if (maxFileKBSize && file[0].size / 1024 > maxFileKBSize) { + setError('ErrorWeight'); + clearInputState(uploaderRef.current); + return; + } + if (!multiply) { + try { + const { width, height } = await getImageDimensions(file[0]); + if (width !== height) { + setError('ErrorSize'); + clearInputState(uploaderRef.current); + return; + } + } catch { + setError('ErrorSize'); + clearInputState(uploaderRef.current); + return; + } + setFiles([file[0]]); onChange([file[0]]); clearInputState(uploaderRef.current); - + setIsUploaded(true); return; } @@ -76,7 +116,9 @@ export const FileLoader = ({ } }; - const onDrop = (e: DragEvent) => { + const onDrop = async (e: DragEvent) => { + setIsUploaded(false); + setError(null); if (disabled) return null; e.preventDefault(); const transferFiles = e.dataTransfer.files; @@ -86,8 +128,24 @@ export const FileLoader = ({ const file = Array.from(transferFiles); if (!multiply) { + if (maxFileKBSize && file[0].size / 1024 > maxFileKBSize) { + setError('ErrorWeight'); + return; + } + try { + const { width, height } = await getImageDimensions(file[0]); + if (width !== height) { + setError('ErrorSize'); + return; + } + } catch { + setError('ErrorSize'); + return; + } + setFiles([file[0]]); onChange([file[0]]); + setIsUploaded(true); return; } @@ -115,15 +173,22 @@ export const FileLoader = ({ { [style.active]: isDragActive, [style.disabled]: disabled, + [style.error]: error, + [style['is-uploaded']]: isUploaded, }, className, )} > {isDragDropEnabled && ( <> -
- {t(Translation.FILE_LOADER_TYPES_PHOTO)} -
+ {!isUploaded && !error && ( +
+ {t(Translation.FILE_LOADER_TYPES_PHOTO)} +
+ )} + + {error &&