diff --git a/src/api/user.ts b/src/api/user.ts index e66b30c1..6fc23d9b 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,6 +1,53 @@ import apiClient from '@/api/apiClient'; import { GetUserResponse } from '@/types/UserTypes'; +/** + * 사용자 프로필 업데이트 요청 타입 + */ +export interface ProfileUpdate { + image?: string; + nickname?: string; +} + +/** + * 현재 로그인된 사용자 정보 조회 + * + * @returns 사용자 정보 객체 + */ export const getUser = (): Promise => { return apiClient.get(`/${process.env.NEXT_PUBLIC_TEAM}/users/me`); }; + +/** + * 프로필 이미지 업로드 요청 + * + * @param file 업로드할 이미지 파일 + * @returns 업로드된 이미지의 URL 문자열 + */ +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('image', file); + + const { url } = await apiClient.post<{ url: string }, { url: string }>( + `/${process.env.NEXT_PUBLIC_TEAM}/images/upload`, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + }, + ); + + return url; +}; + +/** + * 사용자 프로필 정보 수정 + * + * @param profileUpdate 수정할 프로필 필드 (image 또는 nickname 중 일부 혹은 전체) + * @returns 갱신된 사용자 정보 객체 + */ +export const updateProfile = (profileUpdate: ProfileUpdate): Promise => { + return apiClient.patch( + `/${process.env.NEXT_PUBLIC_TEAM}/users/me`, + profileUpdate, + ); +}; diff --git a/src/components/my-profile/Profile.tsx b/src/components/my-profile/Profile.tsx index c0e2a0d5..b59695a4 100644 --- a/src/components/my-profile/Profile.tsx +++ b/src/components/my-profile/Profile.tsx @@ -1,69 +1,142 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm, type SubmitHandler } from 'react-hook-form'; +import { uploadImage, updateProfile } from '@/api/user'; import Input from '@/components/common/Input'; import { Button } from '@/components/ui/button'; +import { useUser } from '@/hooks/useUser'; -interface ProfileProps { - nickname: string; // 현재 사용자 닉네임 (초기값으로 사용) - profileImageUrl: string; // 프로필 이미지 URL (이미지 표시용) -} +import { ProfileImageInput } from './ProfileImageInput'; interface FormValues { - nickname: string; // 폼에서 입력할 닉네임 값 + /** 닉네임 입력 필드 */ + nickname: string; } -export default function Profile({ nickname, profileImageUrl }: ProfileProps) { - // useForm 훅 초기화 +/** + * 유저의 프로필 이미지와 닉네임을 수정할 수 있는 컴포넌트 + * + * - 프로필 이미지 업로드 및 미리보기 + * - 닉네임 변경 + * - 전역 유저 상태 업데이트 + */ +export default function Profile() { + const { user, setUser } = useUser(); + + /** 선택된 이미지 파일 객체 */ + const [selectedFile, setSelectedFile] = useState(null); + + /** 이미지 미리보기용 URL (blob 또는 서버 이미지) */ + const [previewUrl, setPreviewUrl] = useState(null); + + // react-hook-form 사용 const { - register, // input 등록용 함수 - handleSubmit, // 폼 제출 핸들러 래퍼 - watch, // 특정 필드 값 관찰 - reset, // 폼 상태 초기화 - formState: { isSubmitting }, // 제출 중 상태 + register, + handleSubmit, + watch, + reset, + formState: { isSubmitting }, } = useForm({ - defaultValues: { nickname }, // 초기값으로 기존 닉네임 설정 - mode: 'onChange', // 입력 시마다 유효성 검사 실행 + defaultValues: { nickname: user?.nickname ?? '' }, + mode: 'onChange', }); - // 현재 입력된 값을 관찰 + /** + * 유저 정보가 바뀌면 form 초기화 + */ + useEffect(() => { + if (user) { + reset({ nickname: user.nickname }); + } + }, [user, reset]); + + /** + * 선택된 파일로부터 blob URL 생성 (미리보기용) + * → 컴포넌트 언마운트 시 revoke + */ + useEffect(() => { + if (selectedFile) { + const objectUrl = URL.createObjectURL(selectedFile); + setPreviewUrl(objectUrl); + return () => { + URL.revokeObjectURL(objectUrl); + }; + } else { + setPreviewUrl(null); + } + }, [selectedFile]); + + /** 현재 닉네임 입력값 */ const current = watch('nickname'); - // 기존 닉네임과 다르고 비어있지 않을 때만 true - const isChanged = current.trim().length > 0 && current !== nickname; - // 폼 제출 시 호출되는 함수 + /** 닉네임 변경 여부 체크 */ + const isNicknameChanged = current.trim().length > 0 && current !== user?.nickname; + + /** 이미지 변경 여부 체크 */ + const isImageChanged = selectedFile !== null; + + /** 닉네임 또는 이미지가 변경되었는지 여부 */ + const isChanged = isNicknameChanged || isImageChanged; + + /** + * 프로필 수정 form 제출 핸들러 + * + * @param data 닉네임 입력값 + */ const onSubmit: SubmitHandler = async (data) => { + if (!user) return; + try { - // 실제 API 연결 시 axios/fetch 호출로 교체 - await new Promise((r) => setTimeout(r, 1000)); - console.log(`닉네임 변경: ${nickname} → ${data.nickname}`); + let imageUrl = user.image; + + // 이미지 선택 시 업로드 + if (selectedFile) { + imageUrl = await uploadImage(selectedFile); + } + + // 프로필 PATCH 요청 + const updatedUser = await updateProfile({ + nickname: data.nickname, + image: imageUrl ?? undefined, + }); + + // 전역 유저 상태 업데이트 + setUser({ + id: user.id, + nickname: updatedUser.nickname, + image: updatedUser.image ?? null, + teamId: user.teamId, + createdAt: user.createdAt, + updatedAt: new Date().toISOString(), + }); - // 제출 성공 후 폼 상태를 새 기본값으로 초기화 - reset({ nickname: data.nickname }); + // form 초기화 및 파일 제거 + reset({ nickname: updatedUser.nickname }); + setSelectedFile(null); } catch (e) { - // 에러 UI 없이 콘솔에만 출력 - console.error('닉네임 변경 오류:', e); + console.error('프로필 수정 오류:', e); } }; return (
- {/* 프로필 섹션: 이미지 & 현재 닉네임 */} + {/* 프로필 이미지 + 현재 닉네임 */}
-
- {/* 추후 이미지 업로드 기능 추가 필요 */} - 프로필 이미지 + setSelectedFile(file)} + /> +
+ {user?.nickname}
-
{nickname}
{/* 닉네임 변경 폼 */}
- {/* 입력 필드 그룹 */}
- {/* 제출 버튼: 버튼이 좀 이상해서 api 연결 후 수정해보겠습니다다 */} + {/* 변경하기 버튼: 변경사항 없거나 제출 중이면 비활성화 */}
diff --git a/src/components/my-profile/ProfileImageInput.tsx b/src/components/my-profile/ProfileImageInput.tsx new file mode 100644 index 00000000..e7e7ca34 --- /dev/null +++ b/src/components/my-profile/ProfileImageInput.tsx @@ -0,0 +1,104 @@ +import React, { useRef } from 'react'; + +import Image from 'next/image'; + +import Camera from '@/assets/camera.svg'; +import UserDefaultImg from '@/assets/icons/userDefaultImg.svg'; + +interface ProfileImageInputProps { + /** 미리보기 또는 서버에서 받은 프로필 이미지 URL (null이면 기본 이미지 표시) */ + imageUrl?: string | null; + + /** 이미지 파일이 선택되었을 때 부모에게 전달할 콜백 */ + onFileSelect?: (file: File) => void; +} + +/** + * ProfileImageInput + * + * 프로필 이미지 업로드 + 미리보기 컴포넌트 + * + * - `imageUrl`이 있다면 해당 이미지 렌더링 + * - blob:이면 ``, 일반 URL이면 `` + * - 클릭 시 파일 선택창을 열고 이미지 선택 가능 + * - 선택된 파일은 `onFileSelect` 콜백으로 상위 컴포넌트에 전달 + */ +export function ProfileImageInput({ imageUrl, onFileSelect }: ProfileImageInputProps) { + // input[type=file] DOM에 접근하기 위한 ref + const fileInputRef = useRef(null); + + /** + * 프로필 이미지 영역 클릭 시 파일 선택창을 오픈함 + */ + const handleImageClick = () => { + fileInputRef.current?.click(); + }; + + /** + * 이미지 파일 선택 시 onFileSelect 콜백으로 전달 + * + * @param e input[type=file]의 change 이벤트 + */ + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + alert('지원하지 않는 이미지 형식입니다.'); + return; + } + + onFileSelect?.(file); + } + }; + + return ( +
+ {/* 이미지 렌더링 영역 (클릭 시 파일 선택) */} +
+ {/* 프로필 이미지 컨테이너 */} +
+ {/* blob: URL일 경우: 태그 사용 */} + {imageUrl && imageUrl.startsWith('blob:') ? ( + 미리보기 + ) : imageUrl ? ( + // 일반 URL일 경우: next/image 사용 (서버 이미지) + 프로필 이미지 + ) : ( + // 이미지가 없을 경우 기본 아이콘 + + )} +
+ + {/* 마우스 오버 시 카메라 아이콘 오버레이 */} +
+ +
+
+ + {/* 실제 input[type="file"]: hidden 처리 */} + +
+ ); +} diff --git a/src/pages/my-profile/index.tsx b/src/pages/my-profile/index.tsx index 980f08f9..e524c4e4 100644 --- a/src/pages/my-profile/index.tsx +++ b/src/pages/my-profile/index.tsx @@ -5,22 +5,38 @@ import { ReviewList } from '@/components/my-profile/ReviewList'; import { TabNav } from '@/components/my-profile/Tab'; import { WineList } from '@/components/my-profile/WineList'; +/** + * MyProfile + * + * 마이페이지 전체 레이아웃을 구성하는 컴포넌트 + * + * - 프로필 정보(Profile) + * - 탭(TabNav)을 통해 내가 작성한 리뷰/등록한 와인 리스트 조회 + * - 각 리스트의 총 개수를 상위에서 관리 + */ export default function MyProfile() { - // 탭 상태: 'reviews' | 'wines' + /** + * 현재 선택된 탭 상태 + * - 'reviews': 내가 쓴 리뷰 + * - 'wines': 내가 등록한 와인 + */ const [tab, setTab] = useState<'reviews' | 'wines'>('reviews'); - // 각각의 totalCount를 상태로 관리 + /** 리뷰 총 개수 (리뷰 탭에서 ReviewList가 설정함) */ const [reviewsCount, setReviewsCount] = useState(0); + + /** 와인 총 개수 (와인 탭에서 WineList가 설정함) */ const [winesCount, setWinesCount] = useState(0); return (
{/* 프로필 섹션 */} - + - {/* 탭 & 리스트 섹션 */} + {/* 탭 + 리스트 */}
+ {/* 탭 네비게이션: 현재 탭, 탭 전환 함수, 각각의 개수 전달 */} - {/* 탭에 따라 ReviewList 또는 WineList 렌더링 */} + {/* 탭 상태에 따라 리스트 컴포넌트 조건부 렌더링 */} {tab === 'reviews' ? ( ) : (