diff --git a/frontend/src/components/PetProfile/PetInfoInForm.tsx b/frontend/src/components/PetProfile/PetInfoInForm.tsx index d0381ef1..7e5b6a3e 100644 --- a/frontend/src/components/PetProfile/PetInfoInForm.tsx +++ b/frontend/src/components/PetProfile/PetInfoInForm.tsx @@ -5,32 +5,51 @@ import CameraIcon from '@/assets/svg/camera_icon.svg'; import { useImageUpload } from '@/hooks/@common/useImageUpload'; import { PetProfile } from '@/types/petProfile/client'; +import LoadingSpinner from '../@common/LoadingSpinner/LoadingSpinner'; import { getGenderImage, getPetAge } from './PetItem'; interface PetInfoInFormProps { petItem: PetProfile; onChangeImage: (imageUrl: string) => void; + updateIsProcessingImage: (isProcessing: boolean) => void; } const PetInfoInForm = (petInfoInFormProps: PetInfoInFormProps) => { - const { petItem, onChangeImage } = petInfoInFormProps; - const { previewImage, imageUrl, uploadImage } = useImageUpload(); + const { petItem, onChangeImage, updateIsProcessingImage } = petInfoInFormProps; + const { + imageUrl, + previewImage, + compressionPercentage, + isImageBeingCompressed, + isImageBeingUploaded, + uploadCompressedImage, + } = useImageUpload(); useEffect(() => { if (imageUrl) onChangeImage(imageUrl); }, [imageUrl]); + useEffect(() => { + updateIsProcessingImage(isImageBeingCompressed || isImageBeingUploaded); + }, [isImageBeingCompressed, isImageBeingUploaded, updateIsProcessingImage]); + return ( - + + {isImageBeingCompressed && ( + +

이미지 압축 중({compressionPercentage}%)

+
+ )}
+ {isImageBeingUploaded && }
@@ -68,7 +87,6 @@ const ImageUploadLabel = styled.label` height: 10rem; background-color: ${({ theme }) => theme.color.grey200}; - border: 1px solid ${({ theme }) => theme.color.grey300}; border-radius: 50%; & > input { @@ -78,6 +96,7 @@ const ImageUploadLabel = styled.label` const CameraIconWrapper = styled.div` position: absolute; + z-index: 200; right: 0; bottom: 0; @@ -142,9 +161,33 @@ const PetImageWrapper = styled.div` height: 10rem; background-color: ${({ theme }) => theme.color.white}; + border: 1px solid ${({ theme }) => theme.color.grey300}; border-radius: 50%; `; +const ProgressTracker = styled.div` + position: absolute; + z-index: 100; + top: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: inherit; + height: inherit; + + opacity: 0.7; + background-color: ${({ theme }) => theme.color.grey200}; + + & > p { + font-size: 1.2rem; + + opacity: 1; + } +`; + const PetImage = styled.img` position: absolute; top: 0; diff --git a/frontend/src/components/PetProfile/PetProfileEditionForm/PetProfileEditionForm.tsx b/frontend/src/components/PetProfile/PetProfileEditionForm/PetProfileEditionForm.tsx index 6c99c316..b942295c 100644 --- a/frontend/src/components/PetProfile/PetProfileEditionForm/PetProfileEditionForm.tsx +++ b/frontend/src/components/PetProfile/PetProfileEditionForm/PetProfileEditionForm.tsx @@ -19,6 +19,8 @@ const PetProfileEditionForm = () => { isValidNameInput, isValidAgeSelect, isValidWeightInput, + isProcessingImage, + updateIsProcessingImage, onChangeName, onChangeAge, onChangeWeight, @@ -37,6 +39,7 @@ const PetProfileEditionForm = () => { @@ -108,7 +111,7 @@ const PetProfileEditionForm = () => { type="button" $isEditButton onClick={onSubmitNewPetProfile} - disabled={!isValidForm} + disabled={!isValidForm || isProcessingImage} > 수정 diff --git a/frontend/src/components/PetProfile/PetProfileImageUploader.tsx b/frontend/src/components/PetProfile/PetProfileImageUploader.tsx index b8e2eddc..e18948cf 100644 --- a/frontend/src/components/PetProfile/PetProfileImageUploader.tsx +++ b/frontend/src/components/PetProfile/PetProfileImageUploader.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { Dispatch, useEffect } from 'react'; import { styled } from 'styled-components'; import CameraIcon from '@/assets/svg/camera_icon.svg'; @@ -6,23 +6,47 @@ import DefaultDogIcon from '@/assets/svg/dog_icon.svg'; import { usePetAdditionContext } from '@/context/petProfile/PetAdditionContext'; import { useImageUpload } from '@/hooks/@common/useImageUpload'; -const PetProfileImageUploader = () => { +import LoadingSpinner from '../@common/LoadingSpinner/LoadingSpinner'; + +interface PetProfileImageUploaderProps { + updateIsValid?: Dispatch>; +} + +const PetProfileImageUploader = (props: PetProfileImageUploaderProps) => { + const { updateIsValid } = props; const { petProfile, updatePetProfile } = usePetAdditionContext(); - const { previewImage, imageUrl, uploadImage } = useImageUpload(); + const { + imageUrl, + previewImage, + compressionPercentage, + isImageBeingUploaded, + isImageBeingCompressed, + uploadCompressedImage, + } = useImageUpload(); useEffect(() => { if (imageUrl) updatePetProfile({ imageUrl }); }, [imageUrl]); + useEffect(() => { + if (updateIsValid) updateIsValid(!isImageBeingUploaded && !isImageBeingCompressed); + }, [isImageBeingCompressed, isImageBeingUploaded, updateIsValid]); + return ( - + - + {isImageBeingCompressed && ( + +

이미지 압축 중({compressionPercentage}%)

+
+ )} +
+ {isImageBeingUploaded && }
); }; @@ -38,9 +62,33 @@ const PreviewImageWrapper = styled.div` height: 16rem; border: none; + border: 1px solid ${({ theme }) => theme.color.grey300}; border-radius: 50%; `; +const ProgressTracker = styled.div` + position: absolute; + z-index: 100; + top: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: inherit; + height: inherit; + + opacity: 0.7; + background-color: ${({ theme }) => theme.color.grey200}; + + & > p { + font-size: 1.6rem; + + opacity: 1; + } +`; + const PreviewImage = styled.img` position: absolute; top: 0; @@ -69,7 +117,6 @@ const ImageUploadLabel = styled.label` background-repeat: no-repeat; background-position: center; background-size: cover; - border: 1px solid ${({ theme }) => theme.color.grey300}; border-radius: 50%; & > input { @@ -79,6 +126,7 @@ const ImageUploadLabel = styled.label` const CameraIconWrapper = styled.div` position: absolute; + z-index: 200; right: 0; bottom: 0; diff --git a/frontend/src/constants/petProfile.ts b/frontend/src/constants/petProfile.ts index 53a64626..5f5bc2ec 100644 --- a/frontend/src/constants/petProfile.ts +++ b/frontend/src/constants/petProfile.ts @@ -32,7 +32,7 @@ export const PET_ERROR_MESSAGE = { INVALID_WEIGHT: '몸무게는 0kg초과, 100kg이하 소수점 첫째짜리까지 입력이 가능합니다.', } as const; -export const PET_PROFILE_IMAGE_MAX_SIZE = 200; +export const PET_PROFILE_IMAGE_MAX_SIZE = 1000; export const PET_PROFILE_IMAGE_COMPRESSION_OPTION: Options = { maxSizeMB: 1, maxWidthOrHeight: PET_PROFILE_IMAGE_MAX_SIZE, diff --git a/frontend/src/hooks/@common/useImageUpload.ts b/frontend/src/hooks/@common/useImageUpload.ts index 7b92803f..e9ac1207 100644 --- a/frontend/src/hooks/@common/useImageUpload.ts +++ b/frontend/src/hooks/@common/useImageUpload.ts @@ -2,22 +2,25 @@ import imageCompression from 'browser-image-compression'; import { ChangeEvent, useState } from 'react'; import { PET_PROFILE_IMAGE_COMPRESSION_OPTION } from '@/constants/petProfile'; +import { useToast } from '@/context/Toast/ToastContext'; import { useUploadImageMutation } from '@/hooks/query/image'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB; export const useImageUpload = () => { + const { toast } = useToast(); + const { uploadImageMutation } = useUploadImageMutation(); const [previewImage, setPreviewImage] = useState(''); const [imageUrl, setImageUrl] = useState(''); - const { uploadImageMutation } = useUploadImageMutation(); + const [compressionPercentage, setCompressionPercentage] = useState(-1); + const isImageBeingCompressed = compressionPercentage > -1 && compressionPercentage < 100; - const uploadImage = async (e: ChangeEvent) => { + const uploadCompressedImage = async (e: ChangeEvent) => { if (!e.target.files) return; const originalImageFile = e.target.files[0]; if (!originalImageFile) return; - if (originalImageFile.size > MAX_FILE_SIZE) { e.target.value = ''; alert('이미지 크기가 너무 큽니다. 5MB 이하의 이미지를 업로드해주세요.'); @@ -25,20 +28,23 @@ export const useImageUpload = () => { return; } - const compressedImageBlob = await imageCompression( - originalImageFile, - PET_PROFILE_IMAGE_COMPRESSION_OPTION, - ); + setCompressionPercentage(0); + setPreviewImage(URL.createObjectURL(originalImageFile)); const imageUploadFormData = new FormData(); + const compressedImageBlob = await imageCompression(originalImageFile, { + ...PET_PROFILE_IMAGE_COMPRESSION_OPTION, + onProgress: progress => setCompressionPercentage(progress), + }); + + setCompressionPercentage(-1); imageUploadFormData.append('image', compressedImageBlob); uploadImageMutation.uploadImage({ imageFile: imageUploadFormData }).then(data => { setImageUrl(data.imageUrl); + toast.success('이미지 업로드가 완료됐어요!'); }); - - setPreviewImage(URL.createObjectURL(compressedImageBlob)); }; const deletePreviewImage = () => { @@ -47,9 +53,12 @@ export const useImageUpload = () => { }; return { - previewImage, imageUrl, - uploadImage, + previewImage, + compressionPercentage, + isImageBeingUploaded: uploadImageMutation.isLoading, + isImageBeingCompressed, + uploadCompressedImage, deletePreviewImage, }; }; diff --git a/frontend/src/hooks/petProfile/usePetProfileEdition.ts b/frontend/src/hooks/petProfile/usePetProfileEdition.ts index 0daf682e..46a6a13d 100644 --- a/frontend/src/hooks/petProfile/usePetProfileEdition.ts +++ b/frontend/src/hooks/petProfile/usePetProfileEdition.ts @@ -28,6 +28,7 @@ export const usePetProfileEdition = () => { const { removePetMutation } = useRemovePetMutation(); const [pet, setPet] = useState(petItem); + const [isProcessingImage, setIsProcessingImage] = useState(false); const [isValidNameInput, setIsValidNameInput] = useState(true); const [isValidAgeSelect, setIsValidAgeSelect] = useState(true); const [isValidWeightInput, setIsValidWeightInput] = useState(true); @@ -37,6 +38,8 @@ export const usePetProfileEdition = () => { setPet(petItem); }, [petItem]); + const updateIsProcessingImage = (isProcessing: boolean) => setIsProcessingImage(isProcessing); + const onChangeName = (e: ChangeEvent) => { const petName = e.target.value; @@ -125,6 +128,8 @@ export const usePetProfileEdition = () => { isValidNameInput, isValidAgeSelect, isValidWeightInput, + isProcessingImage, + updateIsProcessingImage, onChangeName, onChangeAge, onChangeWeight, diff --git a/frontend/src/pages/PetProfile/PetProfileAddition/PetProfileImageAddition.tsx b/frontend/src/pages/PetProfile/PetProfileAddition/PetProfileImageAddition.tsx index 0d127e10..73f538f9 100644 --- a/frontend/src/pages/PetProfile/PetProfileAddition/PetProfileImageAddition.tsx +++ b/frontend/src/pages/PetProfile/PetProfileAddition/PetProfileImageAddition.tsx @@ -5,16 +5,16 @@ import { usePetProfileAddition } from '@/hooks/petProfile/usePetProfileAddition' import { getTopicParticle } from '@/utils/getTopicParticle'; const PetProfileImageAddition = () => { - const { petProfile, onSubmitPetProfile } = usePetProfileAddition(); + const { petProfile, isValidInput, setIsValidInput, onSubmitPetProfile } = usePetProfileAddition(); return ( {petProfile.name} {`${getTopicParticle(petProfile.name)} 어떤 모습인가요?`} - + - + 등록하기