-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
d676cdc
🎨style: 커스텀 클래스 추가
Insung-Jo 8700381
✨feat: PlusIcon 컴포넌트 구현
Insung-Jo 0644bd4
✨feat: 엔드포인트 작성
Insung-Jo 460e2ef
✨feat: 마이페이지 타입 작성
Insung-Jo e38aad8
✨feat: 마이페이지 사용자 정보 조회 및 수정 API 함수 추가
Insung-Jo 437dd7a
✨feat: 엔드포인트 추가
Insung-Jo 392fc77
✨feat: 마이페이지 타입 추가
Insung-Jo dab06ee
✨feat: 마이페이지 이미지 업로드 API 함수 추가
Insung-Jo cec5cfc
✨feat: 프로필 이미지 업로드 mutation 훅 생성
Insung-Jo 4ae13db
✨feat: 프로필 업데이트 mutation 훅 생성
Insung-Jo 110345d
🫧 modify: PlusIcon 타입 수정 및 반영
Insung-Jo 041933b
✨feat: WhitePenIcon 컴포넌트 구현
Insung-Jo 2cec219
✨feat: CloseCircleIcon 컴포넌트 구현
Insung-Jo 666650e
✨feat: 마이페이지 스키마 작성
Insung-Jo a0b027d
🎨style: Input 조건부 스타일 추가
Insung-Jo 89d13dc
✨feat: 사용자 정보 조회용 useUserQuery 훅 생성
Insung-Jo 1cde8db
✨feat: ProfileImageUpload 컴포넌트 구현
Insung-Jo adb0300
✨feat: ProfileEditForm 컴포넌트 구현
Insung-Jo 34d0251
✨feat: 페이지 적용
Insung-Jo 118ea68
🐛fix: 토큰 안 넘어오던 문제 수정
Insung-Jo 296bae0
♻️refactor: axios 인스턴스 네이밍을 authHttpClient로 명확하게 변경
Insung-Jo 5489a48
🫧modify: 오타 수정
Insung-Jo 76fadb9
🐛fix: 코드래빗 리뷰 반영
Insung-Jo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 serverMessage = ( | ||
| error.response?.data as { message?: string } | undefined | ||
| )?.message | ||
| const fallback = error.message || '프로필 변경에 실패하였습니다.' | ||
| showError(serverMessage ?? fallback) | ||
| } else { | ||
| showError('알 수 없는 에러 발생') | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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> | ||
| ) | ||
| } | ||
102 changes: 102 additions & 0 deletions
102
src/app/features/mypage/components/ProfileImageUpload.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| '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 && preview.startsWith('blob:')) { | ||
| URL.revokeObjectURL(preview) // 메모리 누수 방지 | ||
| } | ||
| setPreview(null) | ||
| onChange(null) // RHF 상태 초기화 | ||
| if (inputRef.current) inputRef.current.value = '' | ||
| } | ||
|
Comment on lines
+29
to
+52
Contributor
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. function으로 함수명 통합하기로 하였던 것으로 기억하는데 const로 선언해주신 이유가 있으실까요? |
||
|
|
||
| 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> | ||
| ) | ||
| } | ||
17 changes: 17 additions & 0 deletions
17
src/app/features/mypage/hook/useUpdateMyProfileMutation.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }) | ||
| } |
11 changes: 11 additions & 0 deletions
11
src/app/features/mypage/hook/useUploadProfileImageMutation.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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자까지 가능합니다.', | ||
| }, | ||
| }, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 처리 사용해주셨군용!