Skip to content

Commit d5cc775

Browse files
authored
fix: 반려동물 프로필 이미지 추가, 수정 시 이미지 업로드 도중 폼 제출 못하도록 수정 (#538)
1 parent d37fe10 commit d5cc775

File tree

7 files changed

+134
-26
lines changed

7 files changed

+134
-26
lines changed

frontend/src/components/PetProfile/PetInfoInForm.tsx

+47-4
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,51 @@ import CameraIcon from '@/assets/svg/camera_icon.svg';
55
import { useImageUpload } from '@/hooks/@common/useImageUpload';
66
import { PetProfile } from '@/types/petProfile/client';
77

8+
import LoadingSpinner from '../@common/LoadingSpinner/LoadingSpinner';
89
import { getGenderImage, getPetAge } from './PetItem';
910

1011
interface PetInfoInFormProps {
1112
petItem: PetProfile;
1213
onChangeImage: (imageUrl: string) => void;
14+
updateIsProcessingImage: (isProcessing: boolean) => void;
1315
}
1416

1517
const PetInfoInForm = (petInfoInFormProps: PetInfoInFormProps) => {
16-
const { petItem, onChangeImage } = petInfoInFormProps;
17-
const { previewImage, imageUrl, uploadImage } = useImageUpload();
18+
const { petItem, onChangeImage, updateIsProcessingImage } = petInfoInFormProps;
19+
const {
20+
imageUrl,
21+
previewImage,
22+
compressionPercentage,
23+
isImageBeingCompressed,
24+
isImageBeingUploaded,
25+
uploadCompressedImage,
26+
} = useImageUpload();
1827

1928
useEffect(() => {
2029
if (imageUrl) onChangeImage(imageUrl);
2130
}, [imageUrl]);
2231

32+
useEffect(() => {
33+
updateIsProcessingImage(isImageBeingCompressed || isImageBeingUploaded);
34+
}, [isImageBeingCompressed, isImageBeingUploaded, updateIsProcessingImage]);
35+
2336
return (
2437
<PetInfoContainer>
2538
<PetImageAndDetail>
2639
<ImageUploadLabel>
27-
<input type="file" accept="image/*" onChange={uploadImage} />
40+
<input type="file" accept="image/*" onChange={uploadCompressedImage} />
2841
<PetImageWrapper>
42+
{isImageBeingCompressed && (
43+
<ProgressTracker>
44+
<p>이미지 압축 중({compressionPercentage}%)</p>
45+
</ProgressTracker>
46+
)}
2947
<PetImage src={previewImage || petItem.imageUrl} alt={petItem.name} />
3048
</PetImageWrapper>
3149
<CameraIconWrapper>
3250
<CameraImage src={CameraIcon} alt="" />
3351
</CameraIconWrapper>
52+
{isImageBeingUploaded && <LoadingSpinner />}
3453
</ImageUploadLabel>
3554
<div>
3655
<GenderAndName>
@@ -68,7 +87,6 @@ const ImageUploadLabel = styled.label`
6887
height: 10rem;
6988
7089
background-color: ${({ theme }) => theme.color.grey200};
71-
border: 1px solid ${({ theme }) => theme.color.grey300};
7290
border-radius: 50%;
7391
7492
& > input {
@@ -78,6 +96,7 @@ const ImageUploadLabel = styled.label`
7896

7997
const CameraIconWrapper = styled.div`
8098
position: absolute;
99+
z-index: 200;
81100
right: 0;
82101
bottom: 0;
83102
@@ -142,9 +161,33 @@ const PetImageWrapper = styled.div`
142161
height: 10rem;
143162
144163
background-color: ${({ theme }) => theme.color.white};
164+
border: 1px solid ${({ theme }) => theme.color.grey300};
145165
border-radius: 50%;
146166
`;
147167

168+
const ProgressTracker = styled.div`
169+
position: absolute;
170+
z-index: 100;
171+
top: 0;
172+
left: 0;
173+
174+
display: flex;
175+
align-items: center;
176+
justify-content: center;
177+
178+
width: inherit;
179+
height: inherit;
180+
181+
opacity: 0.7;
182+
background-color: ${({ theme }) => theme.color.grey200};
183+
184+
& > p {
185+
font-size: 1.2rem;
186+
187+
opacity: 1;
188+
}
189+
`;
190+
148191
const PetImage = styled.img`
149192
position: absolute;
150193
top: 0;

frontend/src/components/PetProfile/PetProfileEditionForm/PetProfileEditionForm.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const PetProfileEditionForm = () => {
1919
isValidNameInput,
2020
isValidAgeSelect,
2121
isValidWeightInput,
22+
isProcessingImage,
23+
updateIsProcessingImage,
2224
onChangeName,
2325
onChangeAge,
2426
onChangeWeight,
@@ -37,6 +39,7 @@ const PetProfileEditionForm = () => {
3739
<PetInfoInForm
3840
petItem={{ ...pet, weight: Number(pet.weight) }}
3941
onChangeImage={onChangeImage}
42+
updateIsProcessingImage={updateIsProcessingImage}
4043
/>
4144
</PetInfoWrapper>
4245

@@ -108,7 +111,7 @@ const PetProfileEditionForm = () => {
108111
type="button"
109112
$isEditButton
110113
onClick={onSubmitNewPetProfile}
111-
disabled={!isValidForm}
114+
disabled={!isValidForm || isProcessingImage}
112115
>
113116
<EditIconImage src={EditIconLight} alt="" />
114117
수정

frontend/src/components/PetProfile/PetProfileImageUploader.tsx

+54-6
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,52 @@
1-
import { useEffect } from 'react';
1+
import { Dispatch, useEffect } from 'react';
22
import { styled } from 'styled-components';
33

44
import CameraIcon from '@/assets/svg/camera_icon.svg';
55
import DefaultDogIcon from '@/assets/svg/dog_icon.svg';
66
import { usePetAdditionContext } from '@/context/petProfile/PetAdditionContext';
77
import { useImageUpload } from '@/hooks/@common/useImageUpload';
88

9-
const PetProfileImageUploader = () => {
9+
import LoadingSpinner from '../@common/LoadingSpinner/LoadingSpinner';
10+
11+
interface PetProfileImageUploaderProps {
12+
updateIsValid?: Dispatch<React.SetStateAction<boolean>>;
13+
}
14+
15+
const PetProfileImageUploader = (props: PetProfileImageUploaderProps) => {
16+
const { updateIsValid } = props;
1017
const { petProfile, updatePetProfile } = usePetAdditionContext();
11-
const { previewImage, imageUrl, uploadImage } = useImageUpload();
18+
const {
19+
imageUrl,
20+
previewImage,
21+
compressionPercentage,
22+
isImageBeingUploaded,
23+
isImageBeingCompressed,
24+
uploadCompressedImage,
25+
} = useImageUpload();
1226

1327
useEffect(() => {
1428
if (imageUrl) updatePetProfile({ imageUrl });
1529
}, [imageUrl]);
1630

31+
useEffect(() => {
32+
if (updateIsValid) updateIsValid(!isImageBeingUploaded && !isImageBeingCompressed);
33+
}, [isImageBeingCompressed, isImageBeingUploaded, updateIsValid]);
34+
1735
return (
1836
<ImageUploadLabel aria-label="사진 업로드하기">
19-
<input type="file" accept="image/*" onChange={uploadImage} />
37+
<input type="file" accept="image/*" onChange={uploadCompressedImage} />
2038
<PreviewImageWrapper>
21-
<PreviewImage src={petProfile.imageUrl || previewImage || DefaultDogIcon} alt="" />
39+
{isImageBeingCompressed && (
40+
<ProgressTracker>
41+
<p>이미지 압축 중({compressionPercentage}%)</p>
42+
</ProgressTracker>
43+
)}
44+
<PreviewImage src={previewImage || petProfile.imageUrl || DefaultDogIcon} alt="" />
2245
</PreviewImageWrapper>
2346
<CameraIconWrapper>
2447
<img src={CameraIcon} alt="" />
2548
</CameraIconWrapper>
49+
{isImageBeingUploaded && <LoadingSpinner />}
2650
</ImageUploadLabel>
2751
);
2852
};
@@ -38,9 +62,33 @@ const PreviewImageWrapper = styled.div`
3862
height: 16rem;
3963
4064
border: none;
65+
border: 1px solid ${({ theme }) => theme.color.grey300};
4166
border-radius: 50%;
4267
`;
4368

69+
const ProgressTracker = styled.div`
70+
position: absolute;
71+
z-index: 100;
72+
top: 0;
73+
left: 0;
74+
75+
display: flex;
76+
align-items: center;
77+
justify-content: center;
78+
79+
width: inherit;
80+
height: inherit;
81+
82+
opacity: 0.7;
83+
background-color: ${({ theme }) => theme.color.grey200};
84+
85+
& > p {
86+
font-size: 1.6rem;
87+
88+
opacity: 1;
89+
}
90+
`;
91+
4492
const PreviewImage = styled.img`
4593
position: absolute;
4694
top: 0;
@@ -69,7 +117,6 @@ const ImageUploadLabel = styled.label`
69117
background-repeat: no-repeat;
70118
background-position: center;
71119
background-size: cover;
72-
border: 1px solid ${({ theme }) => theme.color.grey300};
73120
border-radius: 50%;
74121
75122
& > input {
@@ -79,6 +126,7 @@ const ImageUploadLabel = styled.label`
79126

80127
const CameraIconWrapper = styled.div`
81128
position: absolute;
129+
z-index: 200;
82130
right: 0;
83131
bottom: 0;
84132

frontend/src/constants/petProfile.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const PET_ERROR_MESSAGE = {
3232
INVALID_WEIGHT: '몸무게는 0kg초과, 100kg이하 소수점 첫째짜리까지 입력이 가능합니다.',
3333
} as const;
3434

35-
export const PET_PROFILE_IMAGE_MAX_SIZE = 200;
35+
export const PET_PROFILE_IMAGE_MAX_SIZE = 1000;
3636
export const PET_PROFILE_IMAGE_COMPRESSION_OPTION: Options = {
3737
maxSizeMB: 1,
3838
maxWidthOrHeight: PET_PROFILE_IMAGE_MAX_SIZE,

frontend/src/hooks/@common/useImageUpload.ts

+20-11
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,49 @@ import imageCompression from 'browser-image-compression';
22
import { ChangeEvent, useState } from 'react';
33

44
import { PET_PROFILE_IMAGE_COMPRESSION_OPTION } from '@/constants/petProfile';
5+
import { useToast } from '@/context/Toast/ToastContext';
56
import { useUploadImageMutation } from '@/hooks/query/image';
67

78
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB;
89

910
export const useImageUpload = () => {
11+
const { toast } = useToast();
12+
const { uploadImageMutation } = useUploadImageMutation();
1013
const [previewImage, setPreviewImage] = useState('');
1114
const [imageUrl, setImageUrl] = useState('');
12-
const { uploadImageMutation } = useUploadImageMutation();
15+
const [compressionPercentage, setCompressionPercentage] = useState(-1);
16+
const isImageBeingCompressed = compressionPercentage > -1 && compressionPercentage < 100;
1317

14-
const uploadImage = async (e: ChangeEvent<HTMLInputElement>) => {
18+
const uploadCompressedImage = async (e: ChangeEvent<HTMLInputElement>) => {
1519
if (!e.target.files) return;
1620

1721
const originalImageFile = e.target.files[0];
1822

1923
if (!originalImageFile) return;
20-
2124
if (originalImageFile.size > MAX_FILE_SIZE) {
2225
e.target.value = '';
2326
alert('이미지 크기가 너무 큽니다. 5MB 이하의 이미지를 업로드해주세요.');
2427

2528
return;
2629
}
2730

28-
const compressedImageBlob = await imageCompression(
29-
originalImageFile,
30-
PET_PROFILE_IMAGE_COMPRESSION_OPTION,
31-
);
31+
setCompressionPercentage(0);
32+
setPreviewImage(URL.createObjectURL(originalImageFile));
3233

3334
const imageUploadFormData = new FormData();
35+
const compressedImageBlob = await imageCompression(originalImageFile, {
36+
...PET_PROFILE_IMAGE_COMPRESSION_OPTION,
37+
onProgress: progress => setCompressionPercentage(progress),
38+
});
39+
40+
setCompressionPercentage(-1);
3441

3542
imageUploadFormData.append('image', compressedImageBlob);
3643

3744
uploadImageMutation.uploadImage({ imageFile: imageUploadFormData }).then(data => {
3845
setImageUrl(data.imageUrl);
46+
toast.success('이미지 업로드가 완료됐어요!');
3947
});
40-
41-
setPreviewImage(URL.createObjectURL(compressedImageBlob));
4248
};
4349

4450
const deletePreviewImage = () => {
@@ -47,9 +53,12 @@ export const useImageUpload = () => {
4753
};
4854

4955
return {
50-
previewImage,
5156
imageUrl,
52-
uploadImage,
57+
previewImage,
58+
compressionPercentage,
59+
isImageBeingUploaded: uploadImageMutation.isLoading,
60+
isImageBeingCompressed,
61+
uploadCompressedImage,
5362
deletePreviewImage,
5463
};
5564
};

frontend/src/hooks/petProfile/usePetProfileEdition.ts

+5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const usePetProfileEdition = () => {
2828
const { removePetMutation } = useRemovePetMutation();
2929

3030
const [pet, setPet] = useState<PetInput | undefined>(petItem);
31+
const [isProcessingImage, setIsProcessingImage] = useState(false);
3132
const [isValidNameInput, setIsValidNameInput] = useState(true);
3233
const [isValidAgeSelect, setIsValidAgeSelect] = useState(true);
3334
const [isValidWeightInput, setIsValidWeightInput] = useState(true);
@@ -37,6 +38,8 @@ export const usePetProfileEdition = () => {
3738
setPet(petItem);
3839
}, [petItem]);
3940

41+
const updateIsProcessingImage = (isProcessing: boolean) => setIsProcessingImage(isProcessing);
42+
4043
const onChangeName = (e: ChangeEvent<HTMLInputElement>) => {
4144
const petName = e.target.value;
4245

@@ -125,6 +128,8 @@ export const usePetProfileEdition = () => {
125128
isValidNameInput,
126129
isValidAgeSelect,
127130
isValidWeightInput,
131+
isProcessingImage,
132+
updateIsProcessingImage,
128133
onChangeName,
129134
onChangeAge,
130135
onChangeWeight,

frontend/src/pages/PetProfile/PetProfileAddition/PetProfileImageAddition.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import { usePetProfileAddition } from '@/hooks/petProfile/usePetProfileAddition'
55
import { getTopicParticle } from '@/utils/getTopicParticle';
66

77
const PetProfileImageAddition = () => {
8-
const { petProfile, onSubmitPetProfile } = usePetProfileAddition();
8+
const { petProfile, isValidInput, setIsValidInput, onSubmitPetProfile } = usePetProfileAddition();
99

1010
return (
1111
<Container>
1212
<PetName>{petProfile.name}</PetName>
1313
<Title>{`${getTopicParticle(petProfile.name)} 어떤 모습인가요?`}</Title>
1414
<Content>
15-
<PetProfileImageUploader />
15+
<PetProfileImageUploader updateIsValid={setIsValidInput} />
1616
</Content>
17-
<SubmitButton type="button" onClick={onSubmitPetProfile}>
17+
<SubmitButton type="button" disabled={!isValidInput} onClick={onSubmitPetProfile}>
1818
등록하기
1919
</SubmitButton>
2020
</Container>

0 commit comments

Comments
 (0)