-
Notifications
You must be signed in to change notification settings - Fork 2
✨Feat: 프로필 수정 컴포넌트 구현 #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
d676cdc
8700381
0644bd4
460e2ef
e38aad8
437dd7a
392fc77
dab06ee
cec5cfc
4ae13db
110345d
041933b
2cec219
666650e
a0b027d
89d13dc
1cde8db
adb0300
34d0251
118ea68
296bae0
5489a48
76fadb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,21 @@ | ||
| import api from '@/app/shared/lib/axios' | ||
| import authHttpClient from '@/app/shared/lib/axios' | ||
| import { User as SignupResponse } from '@/app/shared/types/user.type' | ||
|
|
||
| import { LoginRequest, LoginResponse, SignupRequest } from '../types/auth.type' | ||
| import { AUTH_ENDPOINT } from './authEndpoint' | ||
|
|
||
| export const login = async (data: LoginRequest): Promise<LoginResponse> => { | ||
| const response = await api.post<LoginResponse>(AUTH_ENDPOINT.LOGIN, data) | ||
| const response = await authHttpClient.post<LoginResponse>( | ||
| AUTH_ENDPOINT.LOGIN, | ||
| data, | ||
| ) | ||
| return response.data | ||
| } | ||
|
|
||
| export const signup = async (data: SignupRequest): Promise<SignupResponse> => { | ||
| const response = await api.post<SignupResponse>(AUTH_ENDPOINT.SIGNUP, data) | ||
| const response = await authHttpClient.post<SignupResponse>( | ||
| AUTH_ENDPOINT.SIGNUP, | ||
| data, | ||
| ) | ||
| return response.data | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import authHttpClient from '@lib/axios' | ||
|
|
||
| import { User as UserDataResponse } from '@/app/shared/types/user.type' | ||
|
|
||
| import { | ||
| UpdateProfileRequest, | ||
| UploadProfileImageResponse, | ||
| } from '../types/mypage.type' | ||
| import { MYPAGE_ENDPOINT } from './mypageEndPoint' | ||
|
|
||
| export async function loadUser(): Promise<UserDataResponse> { | ||
| const response = await authHttpClient.get(MYPAGE_ENDPOINT.USER) | ||
| return response.data | ||
| } | ||
|
|
||
| export async function updateMyProfile( | ||
| data: UpdateProfileRequest, | ||
| ): Promise<UserDataResponse> { | ||
| const response = await authHttpClient.put<UserDataResponse>( | ||
| MYPAGE_ENDPOINT.USER, | ||
| data, | ||
| ) | ||
| return response.data | ||
| } | ||
|
|
||
| export async function uploadProfileImage( | ||
| image: File, | ||
| ): Promise<UploadProfileImageResponse> { | ||
| const formData = new FormData() | ||
| formData.append('image', image) | ||
| const response = await authHttpClient.post<UploadProfileImageResponse>( | ||
| MYPAGE_ENDPOINT.IMAGE, | ||
| formData, | ||
| ) | ||
| return response.data | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const MYPAGE_ENDPOINT = { | ||
| USER: `/${process.env.NEXT_PUBLIC_TEAM_ID}/users/me`, | ||
| IMAGE: `/${process.env.NEXT_PUBLIC_TEAM_ID}/users/me/image`, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| 'use client' | ||
|
|
||
| import Input from '@components/Input' | ||
| import { showError, showSuccess } from '@lib/toast' | ||
| import { isAxiosError } from 'axios' | ||
| import { useRouter } from 'next/navigation' | ||
| import { useEffect, useState } from 'react' | ||
| import { Controller, useForm } from 'react-hook-form' | ||
|
|
||
| import { useUpdateMyProfileMutation } from '../hook/useUpdateMyProfileMutation' | ||
| import { useUploadProfileImageMutation } from '../hook/useUploadProfileImageMutation' | ||
| import { useUserQuery } from '../hook/useUserQurey' | ||
| import { mypageValidation } from '../schemas/mypageValidation' | ||
| import ProfileImageUpload from './ProfileImageUpload' | ||
|
|
||
| interface ProfileFormData { | ||
| profileImageUrl: string | null | ||
| nickname: string | ||
| email: string | ||
| } | ||
|
|
||
| export default function ProfileEditForm() { | ||
| const { data: user } = useUserQuery() // get으로 사용자 데이터 최신화 | ||
| const router = useRouter() // useClientQuery를 사용 하여 해당 부분에만 렌더링을 진행하려 했으나 다른 부분도 연동되는 부분이 있기 때문에 라우터 사용 | ||
| const { | ||
| control, | ||
| register, | ||
| handleSubmit, | ||
| reset, | ||
| formState: { errors }, | ||
| } = useForm<ProfileFormData>({ | ||
| mode: 'onChange', | ||
| defaultValues: { | ||
| profileImageUrl: user?.profileImageUrl, | ||
| nickname: user?.nickname, | ||
| email: user?.email, | ||
| }, | ||
| }) | ||
|
|
||
| const [profileImageFile, setProfileImageFile] = useState<File | null>(null) | ||
| const { mutateAsync: uploadImage } = useUploadProfileImageMutation() | ||
| const { mutateAsync: updateProfile } = useUpdateMyProfileMutation() | ||
|
|
||
| // 유저 정보가 비동기적으로 넘어오기 때문에 유저가 로딩된 시점에서 RHF을 초기화 하기 위함 (SSR 도입 시 변경 예정) | ||
| useEffect(() => { | ||
| if (user) { | ||
| reset({ | ||
| profileImageUrl: user.profileImageUrl ?? null, | ||
| nickname: user.nickname ?? '', | ||
| email: user.email ?? '', | ||
| }) | ||
| } | ||
| }, [user, reset]) | ||
|
|
||
| async function onSubmit(data: ProfileFormData) { | ||
| try { | ||
| // 현재 이미지 URL을 초기값으로 설정 (변경이 없을 수도 있기 때문) | ||
| let imageUrl = data.profileImageUrl | ||
|
|
||
| // 새 이미지를 업로드한 경우 → 서버에 업로드 요청 (POST) | ||
| if (profileImageFile) { | ||
| const { profileImageUrl } = await uploadImage(profileImageFile) | ||
| imageUrl = profileImageUrl | ||
| } | ||
|
|
||
| // 닉네임과 이미지 URL을 포함한 사용자 프로필 정보 생성 | ||
| const submitData = { | ||
| nickname: data.nickname, | ||
| profileImageUrl: imageUrl, | ||
| } | ||
|
|
||
| // 서버에 프로필 정보 수정 요청 (PUT) | ||
| await updateProfile(submitData) | ||
|
|
||
| // 사용자에게 성공 알림 + 컴포넌트 최신화 | ||
| showSuccess('프로필 변경이 완료되었습니다.') | ||
| router.refresh() | ||
| } catch (error) { | ||
| if (isAxiosError(error)) { | ||
| // 서버 에러 메시지 우선 처리, 없으면 기본 메시지 | ||
| const severMessage = ( | ||
| error.response?.data as { message?: string } | undefined | ||
| )?.message | ||
| const fallback = error.message || '프로필 변경에 실패하였습니다.' | ||
| showError(severMessage ?? fallback) | ||
| } else { | ||
| showError('알 수 없는 에러 발생') | ||
| } | ||
| } | ||
| } | ||
Insung-Jo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return ( | ||
| <form | ||
| onSubmit={handleSubmit(onSubmit)} | ||
| className="BG-white flex h-auto w-full max-w-672 flex-col gap-24 rounded-8 p-24 font-medium" | ||
| > | ||
| <h2 className="text-2xl font-bold">프로필</h2> | ||
|
|
||
| <div className="flex justify-between gap-42 tablet:flex-col"> | ||
| <Controller | ||
| name="profileImageUrl" | ||
| control={control} | ||
| render={({ field: { value, onChange } }) => ( | ||
| <ProfileImageUpload | ||
| value={value} // 미리보기용 이미지 URL | ||
| onChange={onChange} // form 상태(profileImageUrl) 업데이트 | ||
| onFileChange={(file) => setProfileImageFile(file)} // 서버 전송용 파일 저장 | ||
| /> | ||
| )} | ||
| /> | ||
|
|
||
| <div className="flex flex-grow flex-col gap-16"> | ||
| <Input labelName="이메일" {...register('email')} readOnly /> | ||
| <Input | ||
| labelName="닉네임" | ||
| type="text" | ||
| placeholder="닉네임을 입력해 주세요" | ||
| autoComplete="off" | ||
| {...register('nickname', mypageValidation.nickname)} | ||
| hasError={!!errors.nickname} | ||
| errorMessage={errors.nickname?.message} | ||
| /> | ||
| <button | ||
| type="submit" | ||
| className="BG-blue h-50 w-full rounded-8 text-white" | ||
| > | ||
| 저장 | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </form> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,100 @@ | ||||||||||||||||||||||||||||||||||
| 'use client' | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import CloseCircleIcon from '@components/common/CloseCircleIcon/CloseCircleIcon' | ||||||||||||||||||||||||||||||||||
| import PlusIcon from '@components/common/PlusIcon/PlusIcon' | ||||||||||||||||||||||||||||||||||
| import WhitePenIcon from '@components/common/WhitePenIcon/WhitePenIcon' | ||||||||||||||||||||||||||||||||||
| import Image from 'next/image' | ||||||||||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from 'react' | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface Props { | ||||||||||||||||||||||||||||||||||
| value: string | null // RHF에서 연결된 이미지 URL 상태 (미리보기용) | ||||||||||||||||||||||||||||||||||
| onChange: (url: string | null) => void // RHF 필드 상태 변경 함수 | ||||||||||||||||||||||||||||||||||
| onFileChange?: (file: File) => void // 실제 업로드할 파일을 상위에서 처리할 수 있게 전달 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export default function ProfileImageUpload({ | ||||||||||||||||||||||||||||||||||
| value, | ||||||||||||||||||||||||||||||||||
| onChange, | ||||||||||||||||||||||||||||||||||
| onFileChange, | ||||||||||||||||||||||||||||||||||
| }: Props) { | ||||||||||||||||||||||||||||||||||
| const inputRef = useRef<HTMLInputElement | null>(null) | ||||||||||||||||||||||||||||||||||
| const [preview, setPreview] = useState<string | null>(null) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // RHF나 상위 컴포넌트로부터 받은 value가 바뀌면 미리보기를 갱신 | ||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||
| setPreview(value) | ||||||||||||||||||||||||||||||||||
| }, [value]) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 파일이 선택되었을 때 처리 | ||||||||||||||||||||||||||||||||||
| const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||||||||||||||||||||||||
| const file = e.target.files?.[0] | ||||||||||||||||||||||||||||||||||
| if (!file) return | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const url = URL.createObjectURL(file) // 로컬 미리보기 URL 생성 | ||||||||||||||||||||||||||||||||||
| setPreview(url) | ||||||||||||||||||||||||||||||||||
| onChange(url) // RHF 상태 업데이트 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 서버 업로드용 파일을 상위로 전달 | ||||||||||||||||||||||||||||||||||
| if (onFileChange) onFileChange(file) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 같은 파일 다시 선택할 수 있도록 초기화 | ||||||||||||||||||||||||||||||||||
| if (inputRef.current) inputRef.current.value = '' | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 이미지 삭제 처리 | ||||||||||||||||||||||||||||||||||
| const handleDelete = () => { | ||||||||||||||||||||||||||||||||||
| if (preview) URL.revokeObjectURL(preview) // 메모리 누수 방지 | ||||||||||||||||||||||||||||||||||
| setPreview(null) | ||||||||||||||||||||||||||||||||||
| onChange(null) // RHF 상태 초기화 | ||||||||||||||||||||||||||||||||||
| if (inputRef.current) inputRef.current.value = '' | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 메모리 누수 방지 로직을 개선해 주세요. 메모리 누수 방지를 위한 const handleDelete = () => {
- if (preview) URL.revokeObjectURL(preview)
+ if (preview && preview.startsWith('blob:')) {
+ URL.revokeObjectURL(preview)
+ }
setPreview(null)
onChange(null)
if (inputRef.current) inputRef.current.value = ''
}또한 컴포넌트 언마운트 시에도 메모리 정리를 위해 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||
| <div className="relative size-182 basis-182"> | ||||||||||||||||||||||||||||||||||
| {/* 파일 선택 트리거 역할 */} | ||||||||||||||||||||||||||||||||||
| <label | ||||||||||||||||||||||||||||||||||
| htmlFor="userProfile" | ||||||||||||||||||||||||||||||||||
| className="BG-gray group relative flex size-full cursor-pointer items-center justify-center overflow-hidden rounded-lg" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| {preview ? ( | ||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||
| {/* hover 시 연필 아이콘 */} | ||||||||||||||||||||||||||||||||||
| <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100"> | ||||||||||||||||||||||||||||||||||
| <WhitePenIcon size={30} /> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| {/* 이미지 미리보기 */} | ||||||||||||||||||||||||||||||||||
| <Image | ||||||||||||||||||||||||||||||||||
| src={preview} | ||||||||||||||||||||||||||||||||||
| alt="프로필 미리보기" | ||||||||||||||||||||||||||||||||||
| fill | ||||||||||||||||||||||||||||||||||
| className="z-0 object-cover" | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||
| // 아무 이미지도 없을 때 Plus 아이콘 표시 | ||||||||||||||||||||||||||||||||||
| <PlusIcon className="Text-blue" /> | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| {/* 미리보기가 있을 때 삭제 버튼 표시 */} | ||||||||||||||||||||||||||||||||||
| {preview && ( | ||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||
| onClick={handleDelete} | ||||||||||||||||||||||||||||||||||
| className="absolute right-5 top-5 z-20 opacity-50 hover:opacity-100" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <CloseCircleIcon className="Text-gray" /> | ||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||
| id="userProfile" | ||||||||||||||||||||||||||||||||||
| type="file" | ||||||||||||||||||||||||||||||||||
| accept="image/*" | ||||||||||||||||||||||||||||||||||
| className="hidden" | ||||||||||||||||||||||||||||||||||
| ref={inputRef} | ||||||||||||||||||||||||||||||||||
| onChange={handleChange} | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { useMutation } from '@tanstack/react-query' | ||
| import { AxiosError } from 'axios' | ||
|
|
||
| import { User as UpdateProfileResponse } from '@/app/shared/types/user.type' | ||
|
|
||
| import { updateMyProfile } from '../api/mypageApi' | ||
| import { UpdateProfileRequest } from '../types/mypage.type' | ||
|
|
||
| export function useUpdateMyProfileMutation() { | ||
| return useMutation< | ||
| UpdateProfileResponse, | ||
| AxiosError | Error, | ||
| UpdateProfileRequest | ||
| >({ | ||
| mutationFn: updateMyProfile, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { useMutation } from '@tanstack/react-query' | ||
| import { AxiosError } from 'axios' | ||
|
|
||
| import { uploadProfileImage } from '../api/mypageApi' | ||
| import { UploadProfileImageResponse } from '../types/mypage.type' | ||
|
|
||
| export function useUploadProfileImageMutation() { | ||
| return useMutation<UploadProfileImageResponse, AxiosError | Error, File>({ | ||
| mutationFn: uploadProfileImage, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { useQuery } from '@tanstack/react-query' | ||
|
|
||
| import { loadUser } from '../api/mypageApi' | ||
|
|
||
| export function useUserQuery() { | ||
| return useQuery({ | ||
| queryKey: ['loadUser'], | ||
| queryFn: loadUser, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export const mypageValidation = { | ||
| nickname: { | ||
| pattern: { | ||
| value: /^[a-zA-Z가-힣]{1,10}$/, | ||
| message: '한글 또는 영어만 입력할 수 있으며, 최대 10자까지 가능합니다.', | ||
| }, | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export interface UpdateProfileRequest { | ||
| nickname: string | ||
| profileImageUrl: string | null | ||
| } | ||
|
|
||
| export interface UploadProfileImageResponse { | ||
| profileImageUrl: string | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,3 +78,6 @@ body { | |
| .BG-drag-hovered { | ||
| @apply bg-blue-100; | ||
| } | ||
| .Text-blue { | ||
| @apply text-[#83C8FA] dark:text-[#228DFF]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
axiosError 처리 사용해주셨군용!