From d8ebabb04e49fc1b1a9726e51a6c0144e93fb076 Mon Sep 17 00:00:00 2001 From: Radiquum Date: Fri, 21 Mar 2025 15:46:09 +0500 Subject: [PATCH] refactor: CropModal feat: add toasts for collection and profile image changes --- app/components/CropModal/CropModal.tsx | 103 +++++++---- app/components/Profile/Profile.EditModal.tsx | 169 ++++++++++++------- app/pages/CreateCollection.tsx | 109 +++++++++--- 3 files changed, 258 insertions(+), 123 deletions(-) diff --git a/app/components/CropModal/CropModal.tsx b/app/components/CropModal/CropModal.tsx index 893da73..7f42690 100644 --- a/app/components/CropModal/CropModal.tsx +++ b/app/components/CropModal/CropModal.tsx @@ -3,56 +3,86 @@ import Cropper, { ReactCropperElement } from "react-cropper"; import "cropperjs/dist/cropper.css"; import { Button, Modal } from "flowbite-react"; -type Props = { - src: string; - setSrc: (src: string) => void; - setTempSrc: (src: string) => void; +type CropModalProps = { isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - height: number; - width: number; - aspectRatio: number; - guides: boolean; - quality: number; - forceAspect?: boolean; + isActionsDisabled: boolean; + selectedImage: any | null; + croppedImage: any | null; + setCropModalProps: (props: { + isOpen: boolean; + isActionsDisabled: boolean; + selectedImage: any | null; + croppedImage: any | null; + }) => void; + cropParams: { + guides?: boolean; + width?: number; + height?: number; + quality?: number; + aspectRatio?: number; + forceAspect?: boolean; + }; }; -export const CropModal: React.FC = (props) => { +export const CropModal: React.FC = ({ + isOpen, + setCropModalProps, + cropParams, + selectedImage, + croppedImage, + isActionsDisabled, +}) => { const cropperRef = useRef(null); const getCropData = () => { if (typeof cropperRef.current?.cropper !== "undefined") { - props.setSrc( - cropperRef.current?.cropper - .getCroppedCanvas({ - width: props.width, - height: props.height, - maxWidth: props.width, - maxHeight: props.height, - }) - .toDataURL("image/jpeg", props.quality) - ); - props.setTempSrc(""); + const croppedImage = cropperRef.current?.cropper + .getCroppedCanvas({ + width: cropParams.width, + height: cropParams.height, + maxWidth: cropParams.width, + maxHeight: cropParams.height, + }) + .toDataURL( + "image/jpeg", + cropParams.quality || false ? cropParams.quality : 100 + ); + + setCropModalProps({ + isOpen: true, + isActionsDisabled: false, + selectedImage: selectedImage, + croppedImage: croppedImage, + }); } }; return ( props.setIsOpen(false)} + show={isOpen} + onClose={() => { + setCropModalProps({ + isOpen: false, + isActionsDisabled: false, + selectedImage: null, + croppedImage: null, + }); + }} size={"7xl"} > Обрезать изображение @@ -69,23 +99,26 @@ export const CropModal: React.FC = (props) => { diff --git a/app/components/Profile/Profile.EditModal.tsx b/app/components/Profile/Profile.EditModal.tsx index 95eab1c..f9a16c3 100644 --- a/app/components/Profile/Profile.EditModal.tsx +++ b/app/components/Profile/Profile.EditModal.tsx @@ -1,11 +1,11 @@ "use client"; -import { FileInput, Label, Modal } from "flowbite-react"; +import { FileInput, Label, Modal, useThemeMode } from "flowbite-react"; import { Spinner } from "../Spinner/Spinner"; import useSWR from "swr"; import { ENDPOINTS } from "#/api/config"; import { useEffect, useState } from "react"; -import { b64toBlob, unixToDate, useSWRfetcher } from "#/api/utils"; +import { b64toBlob, tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils"; import { ProfileEditPrivacyModal } from "./Profile.EditPrivacyModal"; import { ProfileEditStatusModal } from "./Profile.EditStatusModal"; import { ProfileEditSocialModal } from "./Profile.EditSocialModal"; @@ -13,6 +13,7 @@ import { CropModal } from "../CropModal/CropModal"; import { useSWRConfig } from "swr"; import { useUserStore } from "#/store/auth"; import { ProfileEditLoginModal } from "./Profile.EditLoginModal"; +import { toast } from "react-toastify"; export const ProfileEditModal = (props: { isOpen: boolean; @@ -23,10 +24,7 @@ export const ProfileEditModal = (props: { const [privacyModalOpen, setPrivacyModalOpen] = useState(false); const [statusModalOpen, setStatusModalOpen] = useState(false); const [socialModalOpen, setSocialModalOpen] = useState(false); - const [avatarModalOpen, setAvatarModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false); - const [avatarUri, setAvatarUri] = useState(null); - const [tempAvatarUri, setTempAvatarUri] = useState(null); const [privacyModalSetting, setPrivacyModalSetting] = useState("none"); const [privacySettings, setPrivacySettings] = useState({ privacy_stats: 9, @@ -42,6 +40,14 @@ export const ProfileEditModal = (props: { const [login, setLogin] = useState(""); const { mutate } = useSWRConfig(); const userStore = useUserStore(); + const theme = useThemeMode(); + + const [avatarModalProps, setAvatarModalProps] = useState({ + isOpen: false, + isActionsDisabled: false, + selectedImage: null, + croppedImage: null, + }); const privacy_stat_act_social_text = { 0: "Все пользователи", @@ -67,15 +73,17 @@ export const ProfileEditModal = (props: { `${ENDPOINTS.user.settings.login.info}?token=${props.token}` ); - const handleFileRead = (e, fileReader) => { - const content = fileReader.result; - setTempAvatarUri(content); - }; - - const handleFilePreview = (file) => { + const handleAvatarPreview = (e: any) => { + const file = e.target.files[0]; const fileReader = new FileReader(); - fileReader.onloadend = (e) => { - handleFileRead(e, fileReader); + fileReader.onloadend = () => { + const content = fileReader.result; + setAvatarModalProps({ + ...avatarModalProps, + isOpen: true, + selectedImage: content, + }); + e.target.value = ""; }; fileReader.readAsDataURL(file); }; @@ -103,8 +111,8 @@ export const ProfileEditModal = (props: { }, [loginData]); useEffect(() => { - if (avatarUri) { - let block = avatarUri.split(";"); + async function _uploadAvatar() { + let block = avatarModalProps.croppedImage.split(";"); let contentType = block[0].split(":")[1]; let realData = block[1].split(",")[1]; const blob = b64toBlob(realData, contentType); @@ -112,23 +120,68 @@ export const ProfileEditModal = (props: { const formData = new FormData(); formData.append("image", blob, "cropped.jpg"); formData.append("name", "image"); - const uploadRes = fetch( - `${ENDPOINTS.user.settings.avatar}?token=${props.token}`, - { + + setAvatarModalProps( + (state) => (state = { ...state, isActionsDisabled: true }) + ); + + const tid = toast.loading("Обновление аватара...", { + position: "bottom-center", + hideProgressBar: true, + closeOnClick: false, + pauseOnHover: false, + draggable: false, + theme: theme.mode == "light" ? "light" : "dark", + }); + + const { data, error } = await tryCatchAPI( + fetch(`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, { method: "POST", body: formData, - } - ).then((res) => { - if (res.ok) { - mutate( - `${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` - ); - userStore.checkAuth(); - } + }) + ); + + if (error) { + toast.update(tid, { + render: "Ошибка обновления аватара", + type: "error", + autoClose: 2500, + isLoading: false, + closeOnClick: true, + draggable: true, + }); + setAvatarModalProps( + (state) => (state = { ...state, isActionsDisabled: false }) + ); + return; + } + + toast.update(tid, { + render: "Аватар обновлён", + type: "success", + autoClose: 2500, + isLoading: false, + closeOnClick: true, + draggable: true, }); + setAvatarModalProps( + (state) => + (state = { + isOpen: false, + isActionsDisabled: false, + selectedImage: null, + croppedImage: null, + }) + ); + mutate( + `${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` + ); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [avatarUri]); + + if (avatarModalProps.croppedImage) { + _uploadAvatar(); + } + }, [avatarModalProps.croppedImage]); return ( <> @@ -139,10 +192,9 @@ export const ProfileEditModal = (props: { > Редактирование профиля - {prefLoading ? ( + {prefLoading ? - ) : ( -
+ :
@@ -160,19 +212,18 @@ export const ProfileEditModal = (props: { className="hidden" accept="image/jpg, image/jpeg, image/png" onChange={(e) => { - handleFilePreview(e.target.files[0]); - setAvatarModalOpen(true); + handleAvatarPreview(e); }} />

Изменить фото профиля

- {prefData.is_change_avatar_banned - ? `Заблокировано до ${unixToDate( - prefData.ban_change_avatar_expires, - "full" - )}` - : "Загрузить с устройства"} + {prefData.is_change_avatar_banned ? + `Заблокировано до ${unixToDate( + prefData.ban_change_avatar_expires, + "full" + )}` + : "Загрузить с устройства"}

@@ -197,12 +248,12 @@ export const ProfileEditModal = (props: { >

Изменить никнейм

- {prefData.is_change_login_banned - ? `Заблокировано до ${unixToDate( - prefData.ban_change_login_expires, - "full" - )}` - : login} + {prefData.is_change_login_banned ? + `Заблокировано до ${unixToDate( + prefData.ban_change_login_expires, + "full" + )}` + : login}

- )} + } { const [edit, setEdit] = useState(false); - const [imageUrl, setImageUrl] = useState(null); - const [tempImageUrl, setTempImageUrl] = useState(null); const [isPrivate, setIsPrivate] = useState(false); const [collectionInfo, setCollectionInfo] = useState({ title: "", @@ -51,7 +49,17 @@ export const CreateCollectionPage = () => { const [addedReleases, setAddedReleases] = useState([]); const [addedReleasesIds, setAddedReleasesIds] = useState([]); const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false); - const [cropModalOpen, setCropModalOpen] = useState(false); + + // const [tempImageUrl, setTempImageUrl] = useState(null); + // const [cropModalOpen, setCropModalOpen] = useState(false); + + const [imageModalProps, setImageModalProps] = useState({ + isOpen: false, + isActionsDisabled: false, + selectedImage: null, + croppedImage: null, + }); + const [imageUrl, setImageUrl] = useState(null); const collection_id = searchParams.get("id") || null; const mode = searchParams.get("mode") || null; @@ -118,15 +126,29 @@ export const CreateCollectionPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [userStore.user]); - const handleFileRead = (e, fileReader) => { - const content = fileReader.result; - setTempImageUrl(content); - }; + useEffect(() => { + if (imageModalProps.croppedImage) { + setImageUrl(imageModalProps.croppedImage); + setImageModalProps({ + isOpen: false, + isActionsDisabled: false, + selectedImage: null, + croppedImage: null, + }); + } + }, [imageModalProps.croppedImage]); - const handleFilePreview = (file) => { + const handleImagePreview = (e: any) => { + const file = e.target.files[0]; const fileReader = new FileReader(); - fileReader.onloadend = (e) => { - handleFileRead(e, fileReader); + fileReader.onloadend = () => { + const content = fileReader.result; + setImageModalProps({ + ...imageModalProps, + isOpen: true, + selectedImage: content, + }); + e.target.value = ""; }; fileReader.readAsDataURL(file); }; @@ -203,13 +225,48 @@ export const CreateCollectionPage = () => { const formData = new FormData(); formData.append("image", blob, "cropped.jpg"); formData.append("name", "image"); - await fetch( - `${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`, + + const tiid = toast.loading( + `Обновление обложки коллекции ${collectionInfo.title}...`, { - method: "POST", - body: formData, + position: "bottom-center", + hideProgressBar: true, + closeOnClick: false, + pauseOnHover: false, + draggable: false, + theme: theme.mode == "light" ? "light" : "dark", } ); + + const { data: imageData, error } = await tryCatchAPI( + fetch( + `${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`, + { + method: "POST", + body: formData, + } + ) + ); + + if (error) { + toast.update(tiid, { + render: "Не удалось обновить постер коллекции", + type: "error", + autoClose: 2500, + isLoading: false, + closeOnClick: true, + draggable: true, + }); + } else { + toast.update(tiid, { + render: "Постер коллекции обновлён", + type: "success", + autoClose: 2500, + isLoading: false, + closeOnClick: true, + draggable: true, + }); + } } toast.update(tid, { @@ -328,8 +385,7 @@ export const CreateCollectionPage = () => { className="hidden" accept="image/jpg, image/jpeg, image/png" onChange={(e) => { - handleFilePreview(e.target.files[0]); - setCropModalOpen(true); + handleImagePreview(e); }} /> @@ -439,18 +495,15 @@ export const CreateCollectionPage = () => { setReleasesIds={setAddedReleasesIds} /> );