diff --git a/src/app/features/auth/api/authApi.ts b/src/app/features/auth/api/authApi.ts index b5203fc..49b3576 100644 --- a/src/app/features/auth/api/authApi.ts +++ b/src/app/features/auth/api/authApi.ts @@ -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 => { - const response = await api.post(AUTH_ENDPOINT.LOGIN, data) + const response = await authHttpClient.post( + AUTH_ENDPOINT.LOGIN, + data, + ) return response.data } export const signup = async (data: SignupRequest): Promise => { - const response = await api.post(AUTH_ENDPOINT.SIGNUP, data) + const response = await authHttpClient.post( + AUTH_ENDPOINT.SIGNUP, + data, + ) return response.data } diff --git a/src/app/features/auth/hooks/useLoginMutation.ts b/src/app/features/auth/hooks/useLoginMutation.ts index c7bfeee..76d1fdb 100644 --- a/src/app/features/auth/hooks/useLoginMutation.ts +++ b/src/app/features/auth/hooks/useLoginMutation.ts @@ -22,11 +22,11 @@ export function useLoginMutation() { }, onError: (error) => { if (axios.isAxiosError(error)) { - const severMessage = ( + const serverMessage = ( error.response?.data as { message?: string } | undefined )?.message const fallback = error.message || '로그인 실패' - showError(severMessage ?? fallback) + showError(serverMessage ?? fallback) } else { showError('알 수 없는 에러 발생') } diff --git a/src/app/features/auth/hooks/useSignupMutation.ts b/src/app/features/auth/hooks/useSignupMutation.ts index 039b4c5..d0c9357 100644 --- a/src/app/features/auth/hooks/useSignupMutation.ts +++ b/src/app/features/auth/hooks/useSignupMutation.ts @@ -21,11 +21,11 @@ export function useSignupMutation() { }, onError: (error) => { if (axios.isAxiosError(error)) { - const severMessage = ( + const serverMessage = ( error.response?.data as { message?: string } | undefined )?.message const fallback = error.message || '로그인 실패' - showError(severMessage ?? fallback) + showError(serverMessage ?? fallback) } else { showError('알 수 없는 에러 발생') } diff --git a/src/app/features/mypage/api/mypageApi.ts b/src/app/features/mypage/api/mypageApi.ts new file mode 100644 index 0000000..63c621c --- /dev/null +++ b/src/app/features/mypage/api/mypageApi.ts @@ -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 { + const response = await authHttpClient.get(MYPAGE_ENDPOINT.USER) + return response.data +} + +export async function updateMyProfile( + data: UpdateProfileRequest, +): Promise { + const response = await authHttpClient.put( + MYPAGE_ENDPOINT.USER, + data, + ) + return response.data +} + +export async function uploadProfileImage( + image: File, +): Promise { + const formData = new FormData() + formData.append('image', image) + const response = await authHttpClient.post( + MYPAGE_ENDPOINT.IMAGE, + formData, + ) + return response.data +} diff --git a/src/app/features/mypage/api/mypageEndPoint.ts b/src/app/features/mypage/api/mypageEndPoint.ts new file mode 100644 index 0000000..93880dc --- /dev/null +++ b/src/app/features/mypage/api/mypageEndPoint.ts @@ -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`, +} diff --git a/src/app/features/mypage/components/ProfileEditForm.tsx b/src/app/features/mypage/components/ProfileEditForm.tsx new file mode 100644 index 0000000..5cab41e --- /dev/null +++ b/src/app/features/mypage/components/ProfileEditForm.tsx @@ -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({ + mode: 'onChange', + defaultValues: { + profileImageUrl: user?.profileImageUrl, + nickname: user?.nickname, + email: user?.email, + }, + }) + + const [profileImageFile, setProfileImageFile] = useState(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 ( +
+

프로필

+ +
+ ( + setProfileImageFile(file)} // 서버 전송용 파일 저장 + /> + )} + /> + +
+ + + +
+
+
+ ) +} diff --git a/src/app/features/mypage/components/ProfileImageUpload.tsx b/src/app/features/mypage/components/ProfileImageUpload.tsx new file mode 100644 index 0000000..b2e2cea --- /dev/null +++ b/src/app/features/mypage/components/ProfileImageUpload.tsx @@ -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(null) + const [preview, setPreview] = useState(null) + + // RHF나 상위 컴포넌트로부터 받은 value가 바뀌면 미리보기를 갱신 + useEffect(() => { + setPreview(value) + }, [value]) + + // 파일이 선택되었을 때 처리 + const handleChange = (e: React.ChangeEvent) => { + 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 = '' + } + + return ( +
+ {/* 파일 선택 트리거 역할 */} + + + {/* 미리보기가 있을 때 삭제 버튼 표시 */} + {preview && ( + + )} + + +
+ ) +} diff --git a/src/app/features/mypage/hook/useUpdateMyProfileMutation.ts b/src/app/features/mypage/hook/useUpdateMyProfileMutation.ts new file mode 100644 index 0000000..cc7df94 --- /dev/null +++ b/src/app/features/mypage/hook/useUpdateMyProfileMutation.ts @@ -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, + }) +} diff --git a/src/app/features/mypage/hook/useUploadProfileImageMutation.ts b/src/app/features/mypage/hook/useUploadProfileImageMutation.ts new file mode 100644 index 0000000..08f8fff --- /dev/null +++ b/src/app/features/mypage/hook/useUploadProfileImageMutation.ts @@ -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({ + mutationFn: uploadProfileImage, + }) +} diff --git a/src/app/features/mypage/hook/useUserQurey.ts b/src/app/features/mypage/hook/useUserQurey.ts new file mode 100644 index 0000000..14e3a36 --- /dev/null +++ b/src/app/features/mypage/hook/useUserQurey.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' + +import { loadUser } from '../api/mypageApi' + +export function useUserQuery() { + return useQuery({ + queryKey: ['loadUser'], + queryFn: loadUser, + }) +} diff --git a/src/app/features/mypage/schemas/mypageValidation.ts b/src/app/features/mypage/schemas/mypageValidation.ts new file mode 100644 index 0000000..826bb96 --- /dev/null +++ b/src/app/features/mypage/schemas/mypageValidation.ts @@ -0,0 +1,8 @@ +export const mypageValidation = { + nickname: { + pattern: { + value: /^[a-zA-Z가-힣]{1,10}$/, + message: '한글 또는 영어만 입력할 수 있으며, 최대 10자까지 가능합니다.', + }, + }, +} diff --git a/src/app/features/mypage/types/mypage.type.ts b/src/app/features/mypage/types/mypage.type.ts new file mode 100644 index 0000000..1b0a6de --- /dev/null +++ b/src/app/features/mypage/types/mypage.type.ts @@ -0,0 +1,8 @@ +export interface UpdateProfileRequest { + nickname: string + profileImageUrl: string | null +} + +export interface UploadProfileImageResponse { + profileImageUrl: string +} diff --git a/src/app/globals.css b/src/app/globals.css index 873af03..ab1e400 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -78,3 +78,6 @@ body { .BG-drag-hovered { @apply bg-blue-100; } +.Text-blue { + @apply text-[#83C8FA] dark:text-[#228DFF]; +} diff --git a/src/app/mypage/page.tsx b/src/app/mypage/page.tsx index d73ec75..d89aaf3 100644 --- a/src/app/mypage/page.tsx +++ b/src/app/mypage/page.tsx @@ -1,12 +1,9 @@ 'use client' -import Image from 'next/image' - import Header from '@/app/shared/components/common/header/Header' import Sidebar from '@/app/shared/components/common/sidebar/Sidebar' -import Input from '../shared/components/Input' - +import ProfileEditForm from '../features/mypage/components/ProfileEditForm' export default function Mypage() { return ( <> @@ -35,48 +32,14 @@ export default function Mypage() { strokeLinejoin="round" /> - + {/* 닉네임 프로필 변경 */} -
-

테스트

-
-
- - - - -
- -
- - - -
-
-
+ {/* 비밀번호 변경 */} -
+ {/*

테스트

@@ -86,7 +49,7 @@ export default function Mypage() { 변경
-
+
*/} diff --git a/src/app/shared/components/Input.tsx b/src/app/shared/components/Input.tsx index 137f448..6256958 100644 --- a/src/app/shared/components/Input.tsx +++ b/src/app/shared/components/Input.tsx @@ -45,6 +45,7 @@ const Input = forwardRef( className={cn( 'Text-black h-50 w-full rounded-8 px-16 py-12 text-base font-normal', hasError ? 'Border-error' : 'Border-btn', + props.readOnly && 'Text-gray cursor-default', )} type={inputType} placeholder={placeholder} @@ -72,6 +73,7 @@ const Input = forwardRef( className={cn( 'Text-black h-50 w-full rounded-8 px-16 py-12 text-base font-normal', hasError ? 'Border-error' : 'Border-btn', + props.readOnly && 'Text-gray cursor-default', )} type={inputType} placeholder={placeholder} diff --git a/src/app/shared/components/common/CloseCircleIcon/CloseCircleIcon.tsx b/src/app/shared/components/common/CloseCircleIcon/CloseCircleIcon.tsx new file mode 100644 index 0000000..0919448 --- /dev/null +++ b/src/app/shared/components/common/CloseCircleIcon/CloseCircleIcon.tsx @@ -0,0 +1,40 @@ +interface CloseCircleIconProps extends React.SVGProps { + size?: number +} + +export default function CloseCircleIcon({ + size = 20, + ...props +}: CloseCircleIconProps) { + return ( + <> + + + + + + + ) +} diff --git a/src/app/shared/components/common/PlusIcon/PlusIcon.tsx b/src/app/shared/components/common/PlusIcon/PlusIcon.tsx new file mode 100644 index 0000000..e64b24b --- /dev/null +++ b/src/app/shared/components/common/PlusIcon/PlusIcon.tsx @@ -0,0 +1,38 @@ +interface PlusIconProps extends React.SVGProps { + size?: number + weight?: number +} + +export default function PlusIcon({ + size = 24, + weight = 3, + className, + ...props +}: PlusIconProps) { + return ( + + + + + ) +} diff --git a/src/app/shared/components/common/WhitePenIcon/WhitePenIcon.tsx b/src/app/shared/components/common/WhitePenIcon/WhitePenIcon.tsx new file mode 100644 index 0000000..5595bd7 --- /dev/null +++ b/src/app/shared/components/common/WhitePenIcon/WhitePenIcon.tsx @@ -0,0 +1,20 @@ +interface WhitePenIconProps extends React.SVGProps { + size?: number +} + +export default function WhitePenIcon({ + size = 24, + ...props +}: WhitePenIconProps) { + return ( + + + + ) +} diff --git a/src/app/shared/components/common/sidebar/modal/CreateDashboardModal.tsx b/src/app/shared/components/common/sidebar/modal/CreateDashboardModal.tsx index 837ae65..3332393 100644 --- a/src/app/shared/components/common/sidebar/modal/CreateDashboardModal.tsx +++ b/src/app/shared/components/common/sidebar/modal/CreateDashboardModal.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import React, { useEffect, useState } from 'react' -import api from '@/app/shared/lib/axios' +import authHttpClient from '@/app/shared/lib/axios' import { useModalStore } from '@/app/shared/store/useModalStore' import { CreateDashboardRequest } from '@/app/shared/types/dashboard' @@ -46,7 +46,10 @@ export default function CreateDashboardModal() { throw new Error('NEXT_PUBLIC_TEAM_ID 환경변수가 설정되지 않았습니다.') } - const response = await api.post(`/${process.env.NEXT_PUBLIC_TEAM_ID}/dashboards`, formData) + const response = await authHttpClient.post( + `/${process.env.NEXT_PUBLIC_TEAM_ID}/dashboards`, + formData, + ) const data = response.data diff --git a/src/app/shared/hooks/useDashboard.ts b/src/app/shared/hooks/useDashboard.ts index 936aa2c..044cebe 100644 --- a/src/app/shared/hooks/useDashboard.ts +++ b/src/app/shared/hooks/useDashboard.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' -import api from '../lib/axios' +import authHttpClient from '../lib/axios' import { DashboardListResponse } from '../types/dashboard' export function useDashboard() { @@ -21,7 +21,7 @@ export function useDashboard() { throw new Error('NEXT_PUBLIC_TEAM_ID 환경변수가 설정되지 않았습니다.') } - const response = await api.get( + const response = await authHttpClient.get( `/${process.env.NEXT_PUBLIC_TEAM_ID}/dashboards?navigationMethod=infiniteScroll`, ) setDashboards(response.data.dashboards) diff --git a/src/app/shared/lib/.gitkeep b/src/app/shared/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/shared/lib/axios.ts b/src/app/shared/lib/axios.ts index 877f873..2d83dd3 100644 --- a/src/app/shared/lib/axios.ts +++ b/src/app/shared/lib/axios.ts @@ -1,21 +1,18 @@ import axios from 'axios' -import { AUTH_ENDPOINT } from '@/app/features/auth/api/authEndpoint' import { useAuthStore } from '@/app/features/auth/store/useAuthStore' -const api = axios.create({ +const authHttpClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, }) -api.interceptors.request.use( +authHttpClient.interceptors.request.use( (config) => { const token = useAuthStore.getState().accessToken - const publicPaths = [AUTH_ENDPOINT.LOGIN, AUTH_ENDPOINT.SIGNUP] - const isPulicPath = publicPaths.some((path) => config.url?.includes(path)) - - if (!isPulicPath && token) { + if (token) { config.headers.Authorization = `Bearer ${token}` } + return config }, (error) => { @@ -23,4 +20,4 @@ api.interceptors.request.use( }, ) -export default api +export default authHttpClient