Skip to content

Commit

Permalink
fix: 반려동물 프로필 이미지 추가, 수정 시 이미지 업로드 도중 폼 제출 못하도록 수정 (#538)
Browse files Browse the repository at this point in the history
  • Loading branch information
HyeryongChoi committed Nov 22, 2023
1 parent d37fe10 commit d5cc775
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 26 deletions.
51 changes: 47 additions & 4 deletions frontend/src/components/PetProfile/PetInfoInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PetInfoContainer>
<PetImageAndDetail>
<ImageUploadLabel>
<input type="file" accept="image/*" onChange={uploadImage} />
<input type="file" accept="image/*" onChange={uploadCompressedImage} />
<PetImageWrapper>
{isImageBeingCompressed && (
<ProgressTracker>
<p>이미지 압축 중({compressionPercentage}%)</p>
</ProgressTracker>
)}
<PetImage src={previewImage || petItem.imageUrl} alt={petItem.name} />
</PetImageWrapper>
<CameraIconWrapper>
<CameraImage src={CameraIcon} alt="" />
</CameraIconWrapper>
{isImageBeingUploaded && <LoadingSpinner />}
</ImageUploadLabel>
<div>
<GenderAndName>
Expand Down Expand Up @@ -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 {
Expand All @@ -78,6 +96,7 @@ const ImageUploadLabel = styled.label`

const CameraIconWrapper = styled.div`
position: absolute;
z-index: 200;
right: 0;
bottom: 0;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const PetProfileEditionForm = () => {
isValidNameInput,
isValidAgeSelect,
isValidWeightInput,
isProcessingImage,
updateIsProcessingImage,
onChangeName,
onChangeAge,
onChangeWeight,
Expand All @@ -37,6 +39,7 @@ const PetProfileEditionForm = () => {
<PetInfoInForm
petItem={{ ...pet, weight: Number(pet.weight) }}
onChangeImage={onChangeImage}
updateIsProcessingImage={updateIsProcessingImage}
/>
</PetInfoWrapper>

Expand Down Expand Up @@ -108,7 +111,7 @@ const PetProfileEditionForm = () => {
type="button"
$isEditButton
onClick={onSubmitNewPetProfile}
disabled={!isValidForm}
disabled={!isValidForm || isProcessingImage}
>
<EditIconImage src={EditIconLight} alt="" />
수정
Expand Down
60 changes: 54 additions & 6 deletions frontend/src/components/PetProfile/PetProfileImageUploader.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
import { useEffect } from 'react';
import { Dispatch, useEffect } from 'react';
import { styled } from 'styled-components';

import CameraIcon from '@/assets/svg/camera_icon.svg';
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<React.SetStateAction<boolean>>;
}

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 (
<ImageUploadLabel aria-label="사진 업로드하기">
<input type="file" accept="image/*" onChange={uploadImage} />
<input type="file" accept="image/*" onChange={uploadCompressedImage} />
<PreviewImageWrapper>
<PreviewImage src={petProfile.imageUrl || previewImage || DefaultDogIcon} alt="" />
{isImageBeingCompressed && (
<ProgressTracker>
<p>이미지 압축 중({compressionPercentage}%)</p>
</ProgressTracker>
)}
<PreviewImage src={previewImage || petProfile.imageUrl || DefaultDogIcon} alt="" />
</PreviewImageWrapper>
<CameraIconWrapper>
<img src={CameraIcon} alt="" />
</CameraIconWrapper>
{isImageBeingUploaded && <LoadingSpinner />}
</ImageUploadLabel>
);
};
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -79,6 +126,7 @@ const ImageUploadLabel = styled.label`

const CameraIconWrapper = styled.div`
position: absolute;
z-index: 200;
right: 0;
bottom: 0;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/constants/petProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 20 additions & 11 deletions frontend/src/hooks/@common/useImageUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,49 @@ 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<HTMLInputElement>) => {
const uploadCompressedImage = async (e: ChangeEvent<HTMLInputElement>) => {
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 이하의 이미지를 업로드해주세요.');

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 = () => {
Expand All @@ -47,9 +53,12 @@ export const useImageUpload = () => {
};

return {
previewImage,
imageUrl,
uploadImage,
previewImage,
compressionPercentage,
isImageBeingUploaded: uploadImageMutation.isLoading,
isImageBeingCompressed,
uploadCompressedImage,
deletePreviewImage,
};
};
5 changes: 5 additions & 0 deletions frontend/src/hooks/petProfile/usePetProfileEdition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const usePetProfileEdition = () => {
const { removePetMutation } = useRemovePetMutation();

const [pet, setPet] = useState<PetInput | undefined>(petItem);
const [isProcessingImage, setIsProcessingImage] = useState(false);
const [isValidNameInput, setIsValidNameInput] = useState(true);
const [isValidAgeSelect, setIsValidAgeSelect] = useState(true);
const [isValidWeightInput, setIsValidWeightInput] = useState(true);
Expand All @@ -37,6 +38,8 @@ export const usePetProfileEdition = () => {
setPet(petItem);
}, [petItem]);

const updateIsProcessingImage = (isProcessing: boolean) => setIsProcessingImage(isProcessing);

const onChangeName = (e: ChangeEvent<HTMLInputElement>) => {
const petName = e.target.value;

Expand Down Expand Up @@ -125,6 +128,8 @@ export const usePetProfileEdition = () => {
isValidNameInput,
isValidAgeSelect,
isValidWeightInput,
isProcessingImage,
updateIsProcessingImage,
onChangeName,
onChangeAge,
onChangeWeight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Container>
<PetName>{petProfile.name}</PetName>
<Title>{`${getTopicParticle(petProfile.name)} 어떤 모습인가요?`}</Title>
<Content>
<PetProfileImageUploader />
<PetProfileImageUploader updateIsValid={setIsValidInput} />
</Content>
<SubmitButton type="button" onClick={onSubmitPetProfile}>
<SubmitButton type="button" disabled={!isValidInput} onClick={onSubmitPetProfile}>
등록하기
</SubmitButton>
</Container>
Expand Down

0 comments on commit d5cc775

Please sign in to comment.