From 18550890763fcbfd58d8367ed09c0956efb02dfd Mon Sep 17 00:00:00 2001 From: Security2431 Date: Sun, 3 Dec 2023 13:02:50 +0200 Subject: [PATCH] feat: image dropzone field --- apps/nextjs/package.json | 3 + apps/nextjs/src/app/_components/Dropzone.tsx | 71 +++++++++++++++++++ .../src/app/_components/form/ImageField.tsx | 56 +++++++++++++++ apps/nextjs/src/app/_lib/validations.ts | 21 ++++++ pnpm-lock.yaml | 49 +++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 apps/nextjs/src/app/_components/Dropzone.tsx create mode 100644 apps/nextjs/src/app/_components/form/ImageField.tsx create mode 100644 apps/nextjs/src/app/_lib/validations.ts diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index b9cc2bda..de3544d0 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -31,7 +31,9 @@ "framer-motion": "^10.16.4", "next": "^13.5.4", "react": "18.2.0", + "react-avatar-editor": "14.0.0-beta.5", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.47.0", "react-icons": "^4.11.0", "react-markdown": "^9.0.0", @@ -48,6 +50,7 @@ "@tailwindcss/typography": "^0.5.10", "@types/node": "^18.17.19", "@types/react": "^18.2.25", + "@types/react-avatar-editor": "^13.0.2", "@types/react-dom": "^18.2.10", "dotenv-cli": "^7.3.0", "eslint": "^8.50.0", diff --git a/apps/nextjs/src/app/_components/Dropzone.tsx b/apps/nextjs/src/app/_components/Dropzone.tsx new file mode 100644 index 00000000..d0a1dfc1 --- /dev/null +++ b/apps/nextjs/src/app/_components/Dropzone.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useState } from "react"; +import classNames from "classnames"; +import type { FileRejection } from "react-dropzone"; +import { ErrorCode, useDropzone } from "react-dropzone"; + +import { ACCEPTED_MIME_TYPES, MAX_FILE_SIZE } from "../_lib/constants"; +import { + INVALID_IMAGE_MIME_TYPES, + INVALID_IMAGE_SIZE, +} from "../_lib/validations"; + +/* Props - +============================================================================= */ +interface Props { + className?: string; + children: React.ReactNode; +} + +/* +============================================================================= */ +const Dropzone: React.FC = ({ className, children }) => { + const [selectedFile, setSelectedFile] = useState(null); + const [errors, setErrors] = useState(null); + + // const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); + const onDropRejected = useCallback((fileRejections: FileRejection[]) => { + // Customize errors message + const err = fileRejections[0]?.errors.map((error) => { + return ( + { + [ErrorCode.FileInvalidType]: INVALID_IMAGE_MIME_TYPES, + [ErrorCode.FileTooLarge]: INVALID_IMAGE_SIZE, + }[error.code] || error.message + ); + }); + + setErrors(err); + }, []); + + const onDropAccepted = useCallback((acceptedFiles: File[]) => { + setErrors(null); + setSelectedFile(acceptedFiles[0] ?? null); + }, []); + + // FIXME: types seems to be broken + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + // accept awaits object in format `{'image/*': []}` + accept: ACCEPTED_MIME_TYPES.reduce( + (mimes, type) => ({ ...mimes, [type]: [] }), + {}, + ), + onDropRejected, + onDropAccepted, + multiple: false, + maxSize: MAX_FILE_SIZE, + }); + + return ( +
+ + {isDragActive ? ( +

Drop the files here ...

+ ) : ( +

Drag 'n' drop some files here, or click to select files

+ )} +
+ ); +}; + +export default Dropzone; diff --git a/apps/nextjs/src/app/_components/form/ImageField.tsx b/apps/nextjs/src/app/_components/form/ImageField.tsx new file mode 100644 index 00000000..3af27e0e --- /dev/null +++ b/apps/nextjs/src/app/_components/form/ImageField.tsx @@ -0,0 +1,56 @@ +import { useEffect, useId, useRef, useState } from "react"; +import type { InputHTMLAttributes } from "react"; +import classNames from "classnames"; +import type AvatarEditor from "react-avatar-editor"; +import { ErrorCode, useDropzone } from "react-dropzone"; +import { useFormContext } from "react-hook-form"; +import { toast } from "react-toastify"; + +import Button from "../button"; +import ErrorMessage from "./ErrorMessage"; +import Label from "./Label"; + +/* Props - +============================================================================= */ +type Props = { + label: string; + name: string; + className?: string; +} & InputHTMLAttributes; + +/* +============================================================================= */ +const ImageField: React.FC = ({ label, className, ...props }) => { + const avatarEditorRef = useRef(null); + const [scale, setScale] = useState(1.2); + const id = useId(); + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( +
+ + +
+ +
+ + +
+ ); +}; + +export default ImageField; diff --git a/apps/nextjs/src/app/_lib/validations.ts b/apps/nextjs/src/app/_lib/validations.ts new file mode 100644 index 00000000..b84c663c --- /dev/null +++ b/apps/nextjs/src/app/_lib/validations.ts @@ -0,0 +1,21 @@ +import { MB_BYTES } from "./constants"; + +// Zod validation constants +export const INVALID_EMAIL = "Неправильний E-mail"; +export const INVALID_PASSWORD = "Неправильний пароль"; +export const INVALID_PHONE = "Перевірте правельність номеру телефону"; +export const INVALID_PHONE_STARTS_FROM_ZERO = + "Телефон не може починатися з нуля, перевірте правильність вводу"; +export const INVALID_CONFIRM_PASSWORD = "Паролі не співпадають"; +export const INVALID_IMAGE_MIME_TYPES = + "Підтримуються тільки наступні формати зображення .jpg, .jpeg, .png та .webp."; +export const INVALID_IMAGE_SIZE = `Файл завеликий. Завантажте зображення до ${MB_BYTES}Mb`; + +export const REQUIRED_NAME = "Введіть ваше ім'я"; +export const REQUIRED_EMAIL = "Введіть ваш E-mail"; +export const REQUIRED_PHONE = "Введіть ваш номер телефону"; +export const REQUIRED_PASSWORD = "Пароль має бути довжиною мінімум 8 символів"; +export const REQUIRED_FILE = "Завантажте файл"; + +export const PHONE_REGEX = /^(\+380) \(\d{2}\) \d{3}\-\d{4}$/; +export const PHONE_REGEX_STARTS_FROM_ZERO = /^(\+380) \([^0].*$/; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a1c6163..366167cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,9 +192,15 @@ importers: react: specifier: 18.2.0 version: 18.2.0 + react-avatar-editor: + specifier: 14.0.0-beta.5 + version: 14.0.0-beta.5(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-dropzone: + specifier: ^14.2.3 + version: 14.2.3(react@18.2.0) react-hook-form: specifier: ^7.47.0 version: 7.47.0(react@18.2.0) @@ -238,6 +244,9 @@ importers: '@types/react': specifier: ^18.2.25 version: 18.2.25 + '@types/react-avatar-editor': + specifier: ^13.0.2 + version: 13.0.2 '@types/react-dom': specifier: ^18.2.10 version: 18.2.10 @@ -3558,6 +3567,12 @@ packages: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} dev: false + /@types/react-avatar-editor@13.0.2: + resolution: {integrity: sha512-vnGU4sx5TDB9JMCuw+kB3+jfDNMhVlpBbgMRZG9NzVnaBb5xUjVV4VmHdD2O8gj/pb5GN+QXKk7jan09aMjG2A==} + dependencies: + '@types/react': 18.2.25 + dev: true + /@types/react-dom@18.2.10: resolution: {integrity: sha512-5VEC5RgXIk1HHdyN1pHlg0cOqnxHzvPGpMMyGAP5qSaDRmyZNDaQ0kkVAkK6NYlDhP6YBID3llaXlmAS/mdgCA==} dependencies: @@ -4097,6 +4112,11 @@ packages: engines: {node: '>= 4.0.0'} dev: false + /attr-accept@2.2.2: + resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} + engines: {node: '>=4'} + dev: false + /autoprefixer@10.4.16(postcss@8.4.31): resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} engines: {node: ^10 || ^12 || >=14} @@ -6036,6 +6056,13 @@ packages: dependencies: flat-cache: 3.0.4 + /file-selector@0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.5.0 + dev: false + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -9618,6 +9645,16 @@ packages: strip-json-comments: 2.0.1 dev: false + /react-avatar-editor@14.0.0-beta.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pntWo3iPrOyk6z+X7SwsFV8zaDnUp6GgupI7qDr4uba4d0eSJoIpxE8fxKb4c1KAZkA1vxTeQCBpu51R9GYaUw==} + peerDependencies: + react: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-devtools-core@4.27.7: resolution: {integrity: sha512-12N0HrhCPbD76Z7SkyJdGdXdPGouUsgV6tlEsbSpAnLDO06tjXZP+irht4wPdYwJAJRQ85DxL48eQoz7UmrSuQ==} dependencies: @@ -9638,6 +9675,18 @@ packages: scheduler: 0.23.0 dev: false + /react-dropzone@14.2.3(react@18.2.0): + resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.6.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-fast-compare@3.2.1: resolution: {integrity: sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==} dev: false