Skip to content

Commit

Permalink
♻️ #294 - 프로필 사진 업로드 컴포넌트 개발
Browse files Browse the repository at this point in the history
- 회원가입과 프로필 수정 페이지에서 사용
  • Loading branch information
yws1502 committed Feb 22, 2025
1 parent 23548b8 commit 427e230
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 155 deletions.
108 changes: 24 additions & 84 deletions src/components/account/RegisterProfileImage.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import styled from '@emotion/styled';
import { isAxiosError } from 'axios';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import type { MouseEventHandler, ChangeEventHandler } from 'react';
import type { MouseEventHandler } from 'react';
import type { RegisterForm } from 'types/register';
import type { ErrorResponse } from 'types/response';
import { ImagePickerIcon } from 'assets/icons';
import { ALLOW_IMAGE_TYPES, DEFAULT_PROFILE_IMAGES } from 'constants/profile';
import { ProfileUpload } from 'components/common';
import { DEFAULT_PROFILE_IMAGES } from 'constants/profile';
import { useAlert } from 'hooks/common/useAlert';
import { useImageUpload } from 'hooks/services';
import {
FadeInAnimationStyle,
SVGVerticalAlignStyle,
ScreenReaderOnly,
} from 'styles';
import { FadeInAnimationStyle, SVGVerticalAlignStyle } from 'styles';

export const RegisterProfileImage = () => {
const { setValue } = useFormContext<RegisterForm>();
Expand All @@ -26,8 +19,6 @@ export const RegisterProfileImage = () => {

const { action: alertAction, Alert } = useAlert();

const { mutate: imageUploadMutate } = useImageUpload({ path: 'users' });

useEffect(() => {
if (previewImage.length === 0) {
alertAction('선택된 이미지가 없습니다. 다시 시도해주세요.');
Expand All @@ -37,32 +28,6 @@ export const RegisterProfileImage = () => {
setValue('imgUrl', previewImage);
}, [previewImage]);

const handleImageFile: ChangeEventHandler<HTMLInputElement> = (e) => {
// NOTE: input의 multiple 속성이 false이므로 files length는 최대 1임을 보장합니다.
const file = e.target.files?.[0];
if (!file) return;

// NOTE: input의 accept 속성은 개발자 도구에서 제거할 수 있기 때문에, 이중으로 체크합니다.
if (!ALLOW_IMAGE_TYPES.includes(file.type)) {
alertAction('SVG 파일은 업로드할 수 없습니다.');
return;
}

const imageFormData = new FormData();
imageFormData.append('image', file);

imageUploadMutate(imageFormData, {
onSuccess: (imgUrl) => {
setPreviewImage(imgUrl);
},
onError: (error) => {
if (isAxiosError<ErrorResponse>(error)) {
console.log(error);
}
},
});
};

const handleDefaultProfileImage: MouseEventHandler<HTMLButtonElement> = (
e,
) => {
Expand Down Expand Up @@ -92,36 +57,26 @@ export const RegisterProfileImage = () => {
/>
</PreviewImageContainer>
<ImageFileContainer>
<ImageFileLabel htmlFor="selectImageFile">
<ImagePickerIcon />
</ImageFileLabel>
<ImageFileInput
type="file"
id="selectImageFile"
accept={ALLOW_IMAGE_TYPES.join(', ')}
onChange={handleImageFile}
/>
<>
{DEFAULT_PROFILE_IMAGES.map((image, index) => {
const { id, url } = image;
return (
<ImageButton
key={`default-images-${id}`}
type="button"
onClick={handleDefaultProfileImage}
isActive={url === previewImage}
>
<Image
ref={(element) => (imageRef.current[index] = element)}
src={url}
alt={`기본 프로필 이미지 ${id}`}
width={60}
height={60}
/>
</ImageButton>
);
})}
</>
<ProfileUpload onChange={setPreviewImage} />
{DEFAULT_PROFILE_IMAGES.map((image, index) => {
const { id, url } = image;
return (
<ImageButton
key={`default-images-${id}`}
type="button"
onClick={handleDefaultProfileImage}
isActive={url === previewImage}
>
<Image
ref={(element) => (imageRef.current[index] = element)}
src={url}
alt={`기본 프로필 이미지 ${id}`}
width={60}
height={60}
/>
</ImageButton>
);
})}
</ImageFileContainer>
</Section>
{Alert}
Expand Down Expand Up @@ -167,21 +122,6 @@ const ImageFileContainer = styled.div`
margin: 32px auto;
`;

const ImageFileLabel = styled.label`
display: flex;
align-items: center;
justify-content: center;
width: 60px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colors.bg_02};
aspect-ratio: 1;
cursor: pointer;
`;

const ImageFileInput = styled.input`
${ScreenReaderOnly}
`;

const ImageButton = styled.button<{ isActive: boolean }>`
${SVGVerticalAlignStyle}
overflow: hidden;
Expand Down
75 changes: 75 additions & 0 deletions src/components/common/ProfileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import styled from '@emotion/styled';
import { isAxiosError } from 'axios';
import type { ChangeEventHandler } from 'react';
import type { ErrorResponse } from 'types/response';
import { ImagePickerIcon } from 'assets/icons';
import { ALLOW_IMAGE_TYPES } from 'constants/profile';
import { useAlert } from 'hooks/common/useAlert';
import { useImageUpload } from 'hooks/services';
import { ScreenReaderOnly } from 'styles';

interface ProfileUploadProps {
onChange: (value: string) => void;
}

export const ProfileUpload = ({ onChange }: ProfileUploadProps) => {
const { action: alertAction, Alert } = useAlert();

const { mutate: imageUploadMutate } = useImageUpload({ path: 'users' });

const handleImageFile: ChangeEventHandler<HTMLInputElement> = (e) => {
// NOTE: input의 multiple 속성이 없으로 files length는 최대 1임을 보장합니다.
const file = e.target.files?.[0];
if (!file) return;

// NOTE: input의 accept 속성은 개발자 도구에서 제거할 수 있기 때문에, 이중으로 체크합니다.
if (!ALLOW_IMAGE_TYPES.includes(file.type)) {
alertAction('SVG 파일은 업로드할 수 없습니다.');
return;
}

const imageFormData = new FormData();
imageFormData.append('image', file);

imageUploadMutate(imageFormData, {
onSuccess: (imgUrl) => {
onChange(imgUrl);
},
onError: (error) => {
if (isAxiosError<ErrorResponse>(error)) {
console.log(error);
}
},
});
};

return (
<>
<ImageFileLabel htmlFor="selectImageFile">
<ImagePickerIcon />
</ImageFileLabel>
<ImageFileInput
type="file"
id="selectImageFile"
accept={ALLOW_IMAGE_TYPES.join(', ')}
onChange={handleImageFile}
/>
{Alert}
</>
);
};

const ImageFileLabel = styled.label`
display: flex;
align-items: center;
justify-content: center;
width: 60px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colors.bg_02};
aspect-ratio: 1;
cursor: pointer;
`;

const ImageFileInput = styled.input`
${ScreenReaderOnly}
`;
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ProfileUpload';
export * from './AlertModal';
export * from './Button';
export * from './ColorChip';
Expand Down
93 changes: 22 additions & 71 deletions src/components/profile/SelectProfileImage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import styled from '@emotion/styled';
import { isAxiosError } from 'axios';
import Image from 'next/image';
import {
useRef,
type ChangeEventHandler,
type Dispatch,
type MouseEventHandler,
type SetStateAction,
} from 'react';
import type { ErrorResponse } from 'types/response';
import { ImagePickerIcon } from 'assets/icons';
import { ProfileUpload } from 'components/common';
import { DEFAULT_PROFILE_IMAGES } from 'constants/profile';
import { useImageUpload } from 'hooks/services';
import { ScreenReaderOnly, SVGVerticalAlignStyle } from 'styles';
import { SVGVerticalAlignStyle } from 'styles';

interface SelectProfileImageProps {
previewImage: string;
Expand All @@ -24,26 +20,6 @@ export const SelectProfileImage = ({
setPreviewImage,
}: SelectProfileImageProps) => {
const imageRef = useRef<Array<HTMLImageElement | null>>([]);
const { mutate: imageUploadMutate } = useImageUpload({ path: 'users' });

const handleImageFile: ChangeEventHandler<HTMLInputElement> = (e) => {
const { files } = e.target;
if (files !== null) {
const imageFormData = new FormData();
imageFormData.append('image', files[0]);

imageUploadMutate(imageFormData, {
onSuccess: (imgUrl) => {
setPreviewImage(imgUrl);
},
onError: (error) => {
if (isAxiosError<ErrorResponse>(error)) {
console.log(error);
}
},
});
}
};

const handleDefaultProfileImage: MouseEventHandler<HTMLButtonElement> = (
e,
Expand All @@ -57,36 +33,26 @@ export const SelectProfileImage = ({

return (
<ImageFileContainer>
<ImageFileLabel htmlFor="selectImageFile">
<ImagePickerIcon />
</ImageFileLabel>
<ImageFileInput
type="file"
id="selectImageFile"
accept="image/*"
onChange={handleImageFile}
/>
<>
{DEFAULT_PROFILE_IMAGES.map((image, index) => {
const { id, url } = image;
return (
<ImageButton
key={`default-images-${id}`}
type="button"
onClick={handleDefaultProfileImage}
isActive={url === previewImage}
>
<Image
ref={(element) => (imageRef.current[index] = element)}
src={url}
alt={`기본 프로필 이미지 ${id}`}
width={60}
height={60}
/>
</ImageButton>
);
})}
</>
<ProfileUpload onChange={setPreviewImage} />
{DEFAULT_PROFILE_IMAGES.map((image, index) => {
const { id, url } = image;
return (
<ImageButton
key={`default-images-${id}`}
type="button"
onClick={handleDefaultProfileImage}
isActive={url === previewImage}
>
<Image
ref={(element) => (imageRef.current[index] = element)}
src={url}
alt={`기본 프로필 이미지 ${id}`}
width={60}
height={60}
/>
</ImageButton>
);
})}
</ImageFileContainer>
);
};
Expand All @@ -99,21 +65,6 @@ const ImageFileContainer = styled.div`
margin: 12px auto 36px;
`;

const ImageFileLabel = styled.label`
display: flex;
align-items: center;
justify-content: center;
width: 60px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colors.bg_02};
aspect-ratio: 1;
cursor: pointer;
`;

const ImageFileInput = styled.input`
${ScreenReaderOnly}
`;

const ImageButton = styled.button<{ isActive: boolean }>`
${SVGVerticalAlignStyle}
overflow: hidden;
Expand Down

0 comments on commit 427e230

Please sign in to comment.