Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/api/user.ts
Original file line number Diff line number Diff line change
@@ -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<GetUserResponse> => {
return apiClient.get(`/${process.env.NEXT_PUBLIC_TEAM}/users/me`);
};

/**
* 프로필 이미지 업로드 요청
*
* @param file 업로드할 이미지 파일
* @returns 업로드된 이미지의 URL 문자열
*/
export const uploadImage = async (file: File): Promise<string> => {
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<GetUserResponse> => {
return apiClient.patch<GetUserResponse, GetUserResponse>(
`/${process.env.NEXT_PUBLIC_TEAM}/users/me`,
profileUpdate,
);
};
147 changes: 109 additions & 38 deletions src/components/my-profile/Profile.tsx
Original file line number Diff line number Diff line change
@@ -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<File | null>(null);

/** 이미지 미리보기용 URL (blob 또는 서버 이미지) */
const [previewUrl, setPreviewUrl] = useState<string | null>(null);

// react-hook-form 사용
const {
register, // input 등록용 함수
handleSubmit, // 폼 제출 핸들러 래퍼
watch, // 특정 필드 값 관찰
reset, // 폼 상태 초기화
formState: { isSubmitting }, // 제출 중 상태
register,
handleSubmit,
watch,
reset,
formState: { isSubmitting },
} = useForm<FormValues>({
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<FormValues> = 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 (
<div className='p-5 flex flex-col gap-5 rounded-xl border bg-white xl:justify-between xl:py-7 xl:h-[530px] shadow-md'>
{/* 프로필 섹션: 이미지 & 현재 닉네임 */}
{/* 프로필 이미지 + 현재 닉네임 */}
<div className='flex items-center gap-4 xl:flex-col xl:gap-8'>
<div className='w-16 h-16 rounded-full overflow-hidden xl:w-40 xl:h-40'>
{/* 추후 이미지 업로드 기능 추가 필요 */}
<img src={profileImageUrl} alt='프로필 이미지' className='w-full h-full object-cover' />
<ProfileImageInput
imageUrl={previewUrl ?? user?.image ?? null}
onFileSelect={(file) => setSelectedFile(file)}
/>
<div className='custom-text-xl-bold text-gray-800 md:custom-text-2xl-bold'>
{user?.nickname}
</div>
<div className='custom-text-xl-bold text-gray-800 md:custom-text-2xl-bold'>{nickname}</div>
</div>

{/* 닉네임 변경 폼 */}
<form
onSubmit={handleSubmit(onSubmit)} // react-hook-form 제출 처리
onSubmit={handleSubmit(onSubmit)}
className='flex flex-col items-end gap-1.5 md:flex-row xl:flex-col'
>
{/* 입력 필드 그룹 */}
<div className='flex flex-col w-full gap-[10px]'>
<label
htmlFor='nickname'
Expand All @@ -76,14 +149,12 @@ export default function Profile({ nickname, profileImageUrl }: ProfileProps) {
type='text'
variant='name'
placeholder='새 닉네임을 입력하세요'
defaultValue={nickname} // 초기값 설정
{...register('nickname', {
required: '닉네임을 입력해주세요.',
minLength: { value: 2, message: '최소 2자 이상 입력하세요.' },
maxLength: { value: 20, message: '최대 20자까지 가능합니다.' },
})}
onInvalid={(e: React.FormEvent<HTMLInputElement>) =>
// 브라우저 유효성 오류를 콘솔에만 출력
console.error(
'닉네임 유효성 오류:',
(e.currentTarget as HTMLInputElement).validationMessage,
Expand All @@ -92,16 +163,16 @@ export default function Profile({ nickname, profileImageUrl }: ProfileProps) {
/>
</div>

{/* 제출 버튼: 버튼이 좀 이상해서 api 연결 후 수정해보겠습니다다 */}
{/* 변경하기 버튼: 변경사항 없거나 제출 중이면 비활성화 */}
<Button
type='submit'
variant='purpleDark'
className='min-w-[89px] md:min-w-[116px] xl:min-w-[96px]'
size='sm'
fontSize='md'
disabled={!isChanged || isSubmitting} // 변경된 상태 && 제출 중 아님
disabled={!isChanged || isSubmitting}
>
{isSubmitting ? '변경 중…' : '변경하기'} {/* 제출 중 텍스트 토글 */}
{isSubmitting ? '변경 중…' : '변경하기'}
</Button>
</form>
</div>
Expand Down
104 changes: 104 additions & 0 deletions src/components/my-profile/ProfileImageInput.tsx
Original file line number Diff line number Diff line change
@@ -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:이면 `<img>`, 일반 URL이면 `<Image>`
* - 클릭 시 파일 선택창을 열고 이미지 선택 가능
* - 선택된 파일은 `onFileSelect` 콜백으로 상위 컴포넌트에 전달
*/
export function ProfileImageInput({ imageUrl, onFileSelect }: ProfileImageInputProps) {
// input[type=file] DOM에 접근하기 위한 ref
const fileInputRef = useRef<HTMLInputElement | null>(null);

/**
* 프로필 이미지 영역 클릭 시 파일 선택창을 오픈함
*/
const handleImageClick = () => {
fileInputRef.current?.click();
};

/**
* 이미지 파일 선택 시 onFileSelect 콜백으로 전달
*
* @param e input[type=file]의 change 이벤트
*/
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className='flex flex-col items-center gap-2'>
{/* 이미지 렌더링 영역 (클릭 시 파일 선택) */}
<div
onClick={handleImageClick}
className='group relative overflow-hidden rounded-full w-16 h-16 xl:w-40 xl:h-40 cursor-pointer'
>
{/* 프로필 이미지 컨테이너 */}
<div className='w-full h-full flex items-center justify-center rounded-full border border-gray-300'>
{/* blob: URL일 경우: <img> 태그 사용 */}
{imageUrl && imageUrl.startsWith('blob:') ? (
<img
src={imageUrl}
alt='미리보기'
className='w-full h-full object-cover rounded-full'
/>
) : imageUrl ? (
// 일반 URL일 경우: next/image 사용 (서버 이미지)
<Image
src={imageUrl}
alt='프로필 이미지'
width={64}
height={64}
className='w-full h-full object-cover rounded-full'
unoptimized
priority
/>
) : (
// 이미지가 없을 경우 기본 아이콘
<UserDefaultImg className='w-full h-full text-gray-100' />
)}
</div>

{/* 마우스 오버 시 카메라 아이콘 오버레이 */}
<div className='absolute inset-0 bg-primary/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition'>
<Camera className='w-4 h-4 text-white xl:w-8 xl:h-8' />
</div>
</div>

{/* 실제 input[type="file"]: hidden 처리 */}
<input
type='file'
accept='.png, .jpg, .jpeg, .webp'
onChange={handleFileChange}
ref={fileInputRef}
className='hidden'
/>
</div>
);
}
Loading