diff --git a/src/apis/users/queries.ts b/src/apis/users/queries.ts index 1a17f90..e14c41e 100644 --- a/src/apis/users/queries.ts +++ b/src/apis/users/queries.ts @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; -import { getUser } from '.'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getUser, updateUser } from '.'; +import { UpdateUserForm } from './types'; export const useGetUser = () => { return useQuery({ @@ -7,3 +8,16 @@ export const useGetUser = () => { queryFn: () => getUser(), }); }; + +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: UpdateUserForm) => { + return updateUser(params); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + }); +}; diff --git a/src/apis/users/types.ts b/src/apis/users/types.ts index 254bfb3..45c009f 100644 --- a/src/apis/users/types.ts +++ b/src/apis/users/types.ts @@ -42,20 +42,19 @@ export type SignupFailResponse = FailResponse; export type SignupResponse = Promise; -type ProfileImageUrl = string | URL | null; +const profileImageUrlSchema = z + .instanceof(File) + .refine((file) => ['image/jpeg', 'image/jpg', 'image/png', 'image/ico'].includes(file.type), { + message: PROFILEEDIT_FORM_ERROR_MESSAGE.IMAGE.TYPE, + }) + .refine((file) => file.size < 2 * 1024 * 1024, { message: `2${PROFILEEDIT_FORM_ERROR_MESSAGE.IMAGE.SIZE}` }); export const updateUserFormSchema = z.object({ nickname: z.string().max(PROFILEEDIT_FORM_VALID_LENGTH.NICKNAME.MAX, PROFILEEDIT_FORM_ERROR_MESSAGE.NICKNAME.MAX), - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string().url(), profileImageUrlSchema]).optional().nullable(), }); -export type UpdateUserForm = Omit, 'profileImageUrl'> & { - profileImageUrl: ProfileImageUrl; -}; - -const profileImageUrlSchema = z.instanceof(File).refine((file) => ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml', 'image/ico'].includes(file.type), { - message: '지원되지 않는 이미지 파일입니다.', -}); +export type UpdateUserForm = z.infer; export const createProfileImageFormSchema = z.object({ image: profileImageUrlSchema, @@ -66,7 +65,7 @@ export interface CreateProfileImageForm { } export const profileImageUrlResponseSchema = z.object({ - profileImageUrl: z.union([z.string(), z.instanceof(URL)]), + profileImageUrl: z.string().url(), }); export type ProfileImageUrlResponse = z.infer; diff --git a/src/app/(after-login)/mypage/loading.tsx b/src/app/(after-login)/mypage/loading.tsx new file mode 100644 index 0000000..85131a7 --- /dev/null +++ b/src/app/(after-login)/mypage/loading.tsx @@ -0,0 +1,7 @@ +export default function loading() { + return ( +
+
내정보를 불러오는 중입니다.
+
+ ); +} diff --git a/src/app/(after-login)/mypage/page.tsx b/src/app/(after-login)/mypage/page.tsx index 514f704..50d79b1 100644 --- a/src/app/(after-login)/mypage/page.tsx +++ b/src/app/(after-login)/mypage/page.tsx @@ -1,14 +1,31 @@ +import { redirect } from 'next/navigation'; +import { User, userSchema } from '@/apis/users/types'; import PasswordEdit from '@/components/profile/PasswordEdit'; import ProfileEdit from '@/components/profile/ProfileEdit'; +import GoBackLink from '@/components/ui/Link/GoBackLink'; +import axiosServerHelper from '@/utils/network/axiosServerHelper'; +import { safeResponse } from '@/utils/network/safeResponse'; -export default function MyPage() { - return ( -
- {/* TODO: 돌아가기 컴포넌트 연동 */} +export default async function MyPage() { + const response = await axiosServerHelper('/users/me'); + const userData = safeResponse(response.data, userSchema); + + if (!userData) { + redirect('/login'); + } - + return ( +
+
+ +
+
+ {/* 프로필 수정 */} + - + {/* 비밀번호 수정 */} + +
); } diff --git a/src/assets/images/logo_bi.svg b/src/assets/images/logo_bi.svg index 1abaf84..a88b66c 100644 --- a/src/assets/images/logo_bi.svg +++ b/src/assets/images/logo_bi.svg @@ -1,4 +1,9 @@ - - - + + + + + + + + diff --git a/src/assets/images/logo_ci.svg b/src/assets/images/logo_ci.svg index a88b66c..1abaf84 100644 --- a/src/assets/images/logo_ci.svg +++ b/src/assets/images/logo_ci.svg @@ -1,9 +1,4 @@ - - - - - - - - + + + diff --git a/src/components/auth/Header.tsx b/src/components/auth/Header.tsx index 55e1600..a8fa4d7 100644 --- a/src/components/auth/Header.tsx +++ b/src/components/auth/Header.tsx @@ -1,6 +1,6 @@ import Image from 'next/image'; import Link from 'next/link'; -import LogoCi from '@/assets/images/logo_ci.png'; +import LogoCi from '@/assets/images/logo_ci.svg'; import { ReactNode } from 'react'; export default function Header({ children }: { children: ReactNode }) { diff --git a/src/components/dashboard-header/Profile.tsx b/src/components/dashboard-header/Profile.tsx index 9bea245..17f658f 100644 --- a/src/components/dashboard-header/Profile.tsx +++ b/src/components/dashboard-header/Profile.tsx @@ -45,7 +45,7 @@ export default function Profile() { ) : ( data && (
setIsMenuOpen((prev) => !prev)}> - + {data.nickname} {isMenuOpen && ( diff --git a/src/components/dashboard/CommentSection.tsx b/src/components/dashboard/CommentSection.tsx new file mode 100644 index 0000000..e74453c --- /dev/null +++ b/src/components/dashboard/CommentSection.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { deleteComment, getComments, postComment, putComment } from '@/apis/comments'; +import { Comment, CommentForm } from '@/apis/comments/types'; +import useAlert from '@/hooks/useAlert'; +import { formatDate } from '@/utils/formatDate'; +import { useEffect, useState } from 'react'; +import Avatar from '../ui/Avatar/Avatar'; +import Button from '../ui/Button/Button'; +import { Textarea } from '../ui/Field'; +import { getErrorMessage } from '@/utils/errorMessage'; +import useConfirm from '@/hooks/useConfirm'; + +interface CommentSectionProps { + cardId: number; + columnId: number; + dashboardId: number; +} + +export default function CommentSection({ cardId, columnId, dashboardId }: CommentSectionProps) { + const alert = useAlert(); + const confirm = useConfirm(); + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [content, setContent] = useState(''); + + useEffect(() => { + fetchComments(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cardId]); + + async function fetchComments() { + setIsLoading(true); + try { + const data = await getComments({ cardId, size: 10 }); + setComments(data.comments); + } catch (err) { + const message = getErrorMessage(err); + alert(message); + } finally { + setIsLoading(false); + } + } + + async function handleSubmitComment() { + if (!content.trim()) return; + const formData: CommentForm = { + content, + cardId, + columnId, + dashboardId, + }; + try { + const newComment = await postComment(formData); + setComments((prev) => [newComment, ...prev]); + setContent(''); + } catch (err) { + const message = getErrorMessage(err); + alert(message); + } + } + + async function handleEditComment(commentId: number, currentContent: string) { + const newContent = prompt('댓글 수정하기', currentContent); + if (!newContent) return; + try { + const updated = await putComment(commentId, { content: newContent }); + setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c))); + } catch (err) { + const message = getErrorMessage(err); + alert(message); + } + } + + async function handleDeleteComment(commentId: number) { + const userConfirmed = await confirm('이 카드를 삭제하시겠습니까?', { + buttons: { + ok: '삭제', + cancel: '취소', + }, + }); + + if (!userConfirmed) return; + try { + await deleteComment(commentId); + setComments((prev) => prev.filter((c) => c.id !== commentId)); + } catch (err) { + const message = getErrorMessage(err); + alert(message); + } + } + + return ( +
+ {/* [1] 댓글 입력 영역 */} +
+