From 52b7c9ee9dac6d12fe18ddbfe295e008acca3a0a Mon Sep 17 00:00:00 2001 From: Jiseok Woo <115205098+jisurk@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:24:12 +0900 Subject: [PATCH] =?UTF-8?q?FE-54=F0=9F=94=80=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20main=20merge=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FE-31 상세페이지 UI 제작 (#12) * FE-31💄상세페이지 기본 UI 제작 * FE-31♻️ textarea태그 Textarea컴포넌트로 변경 * FE-31💄 반응형 디자인 추가 --------- Co-authored-by: Woojiseok * FE-43 ✨ 상세페이지 에피그램 조회 (#18) * FE-43✨ 상세페이지 Epigram API연동 * FE-43⚡ ️axios 에러 핸들링 추가 * FE-43🏗️ 상세페이지 Layout 구조개선 * FE-43📝 주석 추가 * FE-43🔥 사용안하는 파일 삭제 * FE-43✏️ 오타 수정 * FE-43 🐛 id없을때 useQuery실행되는 문제 해결 * FE-43♻️ interface->zod 변경 --------- Co-authored-by: 우지석 * FE-43✨ 사용자 ID에 따른 미트볼아이콘 표시 (#22) * FE-43✨ 사용자 ID에 따른 미트볼아이콘 표시 * FE-43✨ 에피그램 상세페이지 더보기 드롭다운 추가 * FE-43💄 MoreOptionMenu 스타일 수정 * FE-31 상세페이지 UI 제작 (#12) * FE-31💄상세페이지 기본 UI 제작 * FE-31♻️ textarea태그 Textarea컴포넌트로 변경 * FE-31💄 반응형 디자인 추가 --------- Co-authored-by: Woojiseok * FE-43 ✨ 상세페이지 에피그램 조회 (#18) * FE-43✨ 상세페이지 Epigram API연동 * FE-43⚡ ️axios 에러 핸들링 추가 * FE-43🏗️ 상세페이지 Layout 구조개선 * FE-43📝 주석 추가 * FE-43🔥 사용안하는 파일 삭제 * FE-43✏️ 오타 수정 * FE-43 🐛 id없을때 useQuery실행되는 문제 해결 * FE-43♻️ interface->zod 변경 --------- Co-authored-by: 우지석 * FE-43✨ 사용자 ID에 따른 미트볼아이콘 표시 (#22) * FE-43✨ 사용자 ID에 따른 미트볼아이콘 표시 * FE-43✨ 에피그램 상세페이지 더보기 드롭다운 추가 * FE-43💄 MoreOptionMenu 스타일 수정 * ✨ 상세페이지 댓글 조회 api연동 (#38) * FE-42💄 EpigramComment안에 CommentCard 추가 * FE-42✨ 상세페이지 댓글 조회 api연동 * FE-42✨ dateUtil함수 추가, 적용 * FE-42✨ 댓글작성자가 본인인지 판별해 수정,삭제 표시 * FE-42🔥 안쓰는 함수 삭제 * FE-42💄 EpigramComment 배경색 수정 * FE-42♻️ CommetCard 구조 개선 * FE-42💄작성된 댓글 없을때 UI 추가 * FE-42🐛 댓글수를 length->totalCount로 변경 * FE-42♻️ useEpigramCommentHook 분리 * FE-42✨ 댓글 목록에 무한스크롤 적용 * FE-42💡 주석 추가 --------- Co-authored-by: 우지석 * FE-76✨ 댓글 작성 api 연동 (#78) * FE-76♻️ 댓글 textarea 컴포넌트 분리 * FE-76💄 switch 컴포넌트 스타일 수정 * FE-76✨ textarea focus out 버튼 추가 * FE-76✨ postComment schema,interface 추가, 수정 * FE-76✨ 댓글 작성 api 연동 * FE-76🐛 import error 해결 * FE-76✨ switch로 댓글 공개,비공개 설정 기능 추가 * FE-76✨ 댓글 작성 시 image를 유저가 등록한 image로 변경 * FE-76🐛 build error 해결 --------- Co-authored-by: 우지석 * FE-45✨ 에피그램 삭제 api 연동 (#80) * FE-45✨ 에피그램 삭제 api 연동 * FE-45💄 에피그램 삭제 모달 추가 * FE-45💄 반응형 디자인 수정 * FE-45♻️ DeleteAlertModal 컴포넌트 분리 * FE-45🐛 build error 해결 --------- Co-authored-by: 우지석 * FE-78✨댓글 수정,삭제 기능 추가 (#91) * FE-78💄 미트볼아이콘 ui수정 * FE-78✨ 댓글 삭제 함수 추가 * FE-78✨댓글 삭제 버튼 기능 추가 * FE-78✨ 댓글 수정 api함수 추가 * FE-78✨ 댓글 수정 기능 추가 * FE-78📝 주석 추가 * FE-78💄 EpigramComment height수정 * FE-78🐛 build error 해결 --------- Co-authored-by: 우지석 * FE-44✨ 에피그램 수정 기능 추가 (#97) * FE-44🚚 상세페이지 페이지 구조 변경 * FE-44✨ 에피그램 수정 api 함수 추가 * FE-44✨ 에피그램 수정 기능 추가 * FE-44♻️ 저자선택관련 함수 useAuthorSelection훅으로 분리 * FE-44💄 EditEpigram,AddEpigram UI수정 * FE-44🐛 출처 유효성검사 버그 수정 * FE-44✨ 작성자 본인이 아닐때 수정페이지 접근 시 리다이렉트 기능 구현 * FE-44🐛 유효성 검사 버그 수정 --------- Co-authored-by: 우지석 * FE-41✨좋아요 기능 추가 (#103) * FE-41♻️ httpClien에t interceoptor 추가 * FE-41✨ 좋아요 api 함수 추가 * FE-41✨ 좋아요 기능 Layout에 적용 * FE-41✨ Url Link버튼 생성 * FE-41💄좋아요,link버튼 스타일 수정 * FE-41🐛 import 에러 해결 * FE-41♻️ 기존header-> Header컴포넌트로 변경 --------- Co-authored-by: 우지석 * FE-54🐛 오타 수정 * build error 해결 --------- Co-authored-by: Woojiseok --- package-lock.json | 7 - public/arrow-left.svg | 3 + public/icon/cancelIcon.svg | 4 + public/likeIcon.svg | 3 + public/logo.svg | 5 + public/meatballIcon.svg | 5 + public/placeLink.svg | 3 + public/profile.svg | 14 + public/share.svg | 5 + src/apis/epigram.ts | 75 +++++ src/apis/queries.ts | 27 +- .../epigram/Comment/CommentItem.tsx | 92 ++++++ .../epigram/Comment/CommentList.tsx | 72 +++++ .../epigram/Comment/CommentTextarea.tsx | 132 ++++++++ src/components/epigram/Comment/NoComment.tsx | 9 + src/components/epigram/DeleteAlertModal.tsx | 29 ++ src/components/epigram/EditEpigram.tsx | 292 ++++++++++++++++++ src/components/epigram/MoreOptionMenu.tsx | 71 +++++ src/components/ui/switch.tsx | 4 +- src/hooks/useAuthorSelectionHook.ts | 51 +++ src/hooks/useDeleteEpigramHook.ts | 21 ++ src/hooks/useEditEpigramHook.ts | 22 ++ src/hooks/useEpigramCommentsQueryHook.ts | 14 + src/hooks/useEpigramLike.ts | 43 +++ src/hooks/useEpigramQueryHook.ts | 11 + src/hooks/usePostCommentHook.ts | 32 ++ src/hooks/userQueryHooks.ts | 8 +- src/lib/dateUtils.ts | 28 ++ src/pageLayout/Epigram/AddEpigram.tsx | 54 +--- src/pageLayout/Epigram/EpigramComment.tsx | 42 +++ src/pageLayout/Epigram/EpigramFigure.tsx | 52 ++++ src/pages/epigram/[id]/edit.tsx | 61 ++++ src/pages/epigram/[id]/index.tsx | 32 ++ src/schema/addEpigram.ts | 7 +- src/schema/user.ts | 4 +- src/types/epigram.types.ts | 4 + 36 files changed, 1274 insertions(+), 64 deletions(-) create mode 100644 public/arrow-left.svg create mode 100644 public/icon/cancelIcon.svg create mode 100644 public/likeIcon.svg create mode 100644 public/logo.svg create mode 100644 public/meatballIcon.svg create mode 100644 public/placeLink.svg create mode 100644 public/profile.svg create mode 100644 public/share.svg create mode 100644 src/apis/epigram.ts create mode 100644 src/components/epigram/Comment/CommentItem.tsx create mode 100644 src/components/epigram/Comment/CommentList.tsx create mode 100644 src/components/epigram/Comment/CommentTextarea.tsx create mode 100644 src/components/epigram/Comment/NoComment.tsx create mode 100644 src/components/epigram/DeleteAlertModal.tsx create mode 100644 src/components/epigram/EditEpigram.tsx create mode 100644 src/components/epigram/MoreOptionMenu.tsx create mode 100644 src/hooks/useAuthorSelectionHook.ts create mode 100644 src/hooks/useDeleteEpigramHook.ts create mode 100644 src/hooks/useEditEpigramHook.ts create mode 100644 src/hooks/useEpigramCommentsQueryHook.ts create mode 100644 src/hooks/useEpigramLike.ts create mode 100644 src/hooks/useEpigramQueryHook.ts create mode 100644 src/hooks/usePostCommentHook.ts create mode 100644 src/lib/dateUtils.ts create mode 100644 src/pageLayout/Epigram/EpigramComment.tsx create mode 100644 src/pageLayout/Epigram/EpigramFigure.tsx create mode 100644 src/pages/epigram/[id]/edit.tsx create mode 100644 src/pages/epigram/[id]/index.tsx diff --git a/package-lock.json b/package-lock.json index ce9380b1..b326f467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.50.0", - "@types/lodash": "^4.17.7", "@types/node": "^20.14.10", "@types/qs": "^6.9.15", "@types/react": "^18.3.3", @@ -1937,12 +1936,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", - "dev": true - }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", diff --git a/public/arrow-left.svg b/public/arrow-left.svg new file mode 100644 index 00000000..225646e9 --- /dev/null +++ b/public/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/icon/cancelIcon.svg b/public/icon/cancelIcon.svg new file mode 100644 index 00000000..678aaf2f --- /dev/null +++ b/public/icon/cancelIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/likeIcon.svg b/public/likeIcon.svg new file mode 100644 index 00000000..b88fa457 --- /dev/null +++ b/public/likeIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..fc1d4f6a --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/meatballIcon.svg b/public/meatballIcon.svg new file mode 100644 index 00000000..49a9368e --- /dev/null +++ b/public/meatballIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/placeLink.svg b/public/placeLink.svg new file mode 100644 index 00000000..1cdd89a7 --- /dev/null +++ b/public/placeLink.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/profile.svg b/public/profile.svg new file mode 100644 index 00000000..10fe1c70 --- /dev/null +++ b/public/profile.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/share.svg b/public/share.svg new file mode 100644 index 00000000..0ff446dd --- /dev/null +++ b/public/share.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/apis/epigram.ts b/src/apis/epigram.ts new file mode 100644 index 00000000..418d9bc6 --- /dev/null +++ b/src/apis/epigram.ts @@ -0,0 +1,75 @@ +import axios, { AxiosError } from 'axios'; +import { GetEpigramResponseType, EpigramRequestType } from '@/schema/epigram'; +import { DeleteEpigramType } from '@/types/epigram.types'; +import { AddEpigramResponseType, EditEpigramRequestType } from '@/schema/addEpigram'; +import httpClient from '.'; + +export const getEpigram = async (request: EpigramRequestType): Promise => { + const { id } = request; + + if (id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + + try { + const response = await httpClient.get(`/epigrams/${id}`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new Error(`API 에러: ${axiosError.response.status}`); + } else if (axiosError.request) { + throw new Error('서버로부터 응답을 받지 못했습니다.'); + } else { + throw new Error('요청 설정 중 오류가 발생했습니다.'); + } + } else { + throw new Error('예상치 못한 오류가 발생했습니다.'); + } + } +}; + +export const deleteEpigram = async (id: number): Promise => { + const response = await httpClient.delete(`/epigrams/${id}`); + return response.data; +}; + +// NOTE: 에피그램 수정 api 함수 +export const patchEpigram = async (request: EditEpigramRequestType): Promise => { + const { id, ...data } = request; + const response = await httpClient.patch(`/epigrams/${id}`, data); + return response.data; +}; + +export const toggleEpigramLike = async (request: EpigramRequestType): Promise => { + const { id } = request; + + if (id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + + try { + const response = await httpClient.post(`/epigrams/${id}/like`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response?.status === 400) { + // 이미 좋아요를 눌렀다면, 좋아요 취소 요청을 보냅니다. + const response = await httpClient.delete(`/epigrams/${id}/like`); + return response.data; + } + // 그 외의 에러 처리 + if (axiosError.response) { + throw new Error(`API 에러: ${axiosError.response.status}`); + } else if (axiosError.request) { + throw new Error('서버로부터 응답을 받지 못했습니다.'); + } else { + throw new Error('요청 설정 중 오류가 발생했습니다.'); + } + } else { + throw new Error('예상치 못한 오류가 발생했습니다.'); + } + } +}; diff --git a/src/apis/queries.ts b/src/apis/queries.ts index 0010c375..ddfed703 100644 --- a/src/apis/queries.ts +++ b/src/apis/queries.ts @@ -1,10 +1,14 @@ import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import { GetUserRequestType } from '@/schema/user'; +import { EpigramRequestType } from '@/schema/epigram'; +import { CommentRequestType } from '@/schema/comment'; import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; import { getMe, getUser } from './user'; +import { getEpigram } from './epigram'; +import { getEpigramComments } from './epigramComment'; import getMonthlyEmotionLogs from './emotion'; -const quries = createQueryKeyStore({ +const queries = createQueryKeyStore({ user: { getMe: () => ({ queryKey: ['getMe'], @@ -15,6 +19,25 @@ const quries = createQueryKeyStore({ queryFn: () => getUser(request), }), }, + // NOTE: Epigram 관련 query함수 + epigram: { + getEpigram: (request: EpigramRequestType) => ({ + queryKey: ['epigram', request.id, request], + queryFn: () => { + if (request.id === undefined) { + throw new Error('Epigram ID가 제공되지 않았습니다.'); + } + return getEpigram(request); + }, + enabled: request.id !== undefined, + }), + }, + epigramComment: { + getComments: (request: CommentRequestType) => ({ + queryKey: ['epigramComments', request], + queryFn: () => getEpigramComments(request), + }), + }, emotion: { getMonthlyEmotionLogs: (request: GetMonthlyEmotionLogsRequestType) => ({ queryKey: ['getMonthlyEmotionLogs', request], @@ -23,4 +46,4 @@ const quries = createQueryKeyStore({ }, }); -export default quries; +export default queries; diff --git a/src/components/epigram/Comment/CommentItem.tsx b/src/components/epigram/Comment/CommentItem.tsx new file mode 100644 index 00000000..dd65937c --- /dev/null +++ b/src/components/epigram/Comment/CommentItem.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { CommentType } from '@/schema/comment'; +import { sizeStyles, textSizeStyles, gapStyles, paddingStyles, contentWidthStyles } from '@/styles/CommentCardStyles'; +import getCustomRelativeTime from '@/lib/dateUtils'; +import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook'; +import { CommentCardProps } from '@/components/Card/CommentCard'; +import { useToast } from '@/components/ui/use-toast'; +import { Button } from '@/components/ui/button'; +import DeleteAlertModal from '../DeleteAlertModal'; + +interface CommentItemProps extends CommentCardProps { + comment: CommentType; + onEditComment: (id: number, content: string, isPrivate: boolean) => void; +} + +function CommentItem({ comment, status, onEditComment }: CommentItemProps) { + const deleteCommentMutation = useDeleteCommentMutation(); + const { toast } = useToast(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleEditClick = () => { + onEditComment(comment.id, comment.content, comment.isPrivate); + }; + + // NOTE: 댓글 삭제 + const handleDeleteComment = async () => { + try { + await deleteCommentMutation.mutateAsync(comment.id); + setIsDeleteModalOpen(false); + toast({ + title: '댓글이 삭제되었습니다.', + variant: 'destructive', + }); + } catch (error) { + toast({ + title: '댓글 삭제 실패했습니다.', + variant: 'destructive', + }); + } + }; + + return ( +
+
+
+
+ 프로필 이미지 +
+
+
+
+
+
{comment.writer.nickname}
+
+ {getCustomRelativeTime(comment.createdAt)} +
+
+ {status === 'edit' && ( +
+ + +
+ )} +
+
+ {comment.content} +
+
+
+ +
+ ); +} + +export default CommentItem; diff --git a/src/components/epigram/Comment/CommentList.tsx b/src/components/epigram/Comment/CommentList.tsx new file mode 100644 index 00000000..4546016b --- /dev/null +++ b/src/components/epigram/Comment/CommentList.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { EpigramCommentProps } from '@/types/epigram.types'; +import useEpigramCommentsQuery from '@/hooks/useEpigramCommentsQueryHook'; +import CommentItem from './CommentItem'; +import NoComment from './NoComment'; + +interface CommentListProps extends Omit { + onEditComment: (id: number, content: string, isPrivate: boolean) => void; +} + +function CommentList({ epigramId, currentUserId, onEditComment }: CommentListProps) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useEpigramCommentsQuery(epigramId); + + const observerRef = useRef(null); + const lastCommentRef = useRef(null); + + // NOTE: Observer 콜백: 마지막 요소가 화면에 보이면 다음 페이지(댓글 최대3개를 묶어 1페이지 취급) 로드 + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [target] = entries; + if (target.isIntersecting && hasNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage], + ); + + useEffect(() => { + const options = { + root: null, + rootMargin: '20px', + threshold: 1.0, + }; + + observerRef.current = new IntersectionObserver(handleObserver, options); + + if (lastCommentRef.current) { + observerRef.current.observe(lastCommentRef.current); + } + + return () => { + // NOTE: effect가 실행되기전에 호출해서 메모리 누수를 방지해줌 + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [handleObserver]); + + if (status === 'pending') return
댓글을 불러오는 중...
; + if (status === 'error') return
에러: 댓글을 불러오는데 실패했습니다.
; + + const allComments = data?.pages.flatMap((page) => page.list) || []; + const totalCount = data?.pages[0]?.totalCount || 0; + + return ( +
+

댓글({totalCount})

+ {allComments.length > 0 ? ( + <> + {allComments.map((comment) => ( + + ))} +
{isFetchingNextPage &&
더 많은 댓글을 불러오는 중...
}
+ + ) : ( + + )} +
+ ); +} + +export default CommentList; diff --git a/src/components/epigram/Comment/CommentTextarea.tsx b/src/components/epigram/Comment/CommentTextarea.tsx new file mode 100644 index 00000000..707ee760 --- /dev/null +++ b/src/components/epigram/Comment/CommentTextarea.tsx @@ -0,0 +1,132 @@ +import React, { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; +import Label from '@/components/ui/label'; +import Switch from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import Image from 'next/image'; +import { CommentFormSchema, CommentFormValues } from '@/schema/comment'; +import usePostCommentMutation from '@/hooks/usePostCommentHook'; +import usePatchCommentMutation from '@/hooks/usePatchCommentHook'; + +interface CommentTextareaProps { + epigramId: number; + editingComment: { id: number; content: string; isPrivate: boolean } | null; + onEditComplete: () => void; +} + +function CommentTextarea({ epigramId, editingComment, onEditComplete }: CommentTextareaProps) { + const postCommentMutation = usePostCommentMutation(); + const patchCommentMutation = usePatchCommentMutation(); + + const form = useForm({ + resolver: zodResolver(CommentFormSchema), + defaultValues: { + content: '', + isPrivate: false, + }, + }); + + // NOTE: 수정중인지 새댓글작성중인지의 상태가 변활때 폼 초기화 + useEffect(() => { + if (editingComment !== null) { + form.reset({ + content: editingComment.content, + isPrivate: editingComment.isPrivate, + }); + } else { + form.reset({ + content: '', + isPrivate: false, + }); + } + }, [editingComment, form]); + + const onSubmit = (values: CommentFormValues) => { + if (editingComment) { + // NOTE: 댓글 수정 시 + patchCommentMutation.mutate( + { commentId: editingComment.id, ...values }, + { + onSuccess: () => { + form.reset({ content: '', isPrivate: false }); + onEditComplete(); + }, + }, + ); + } else { + // NOTE: 새 댓글 작성 시 + const commentData = { + epigramId, + ...values, + }; + postCommentMutation.mutate(commentData, { + onSuccess: () => { + form.reset({ content: '', isPrivate: false }); + }, + }); + } + }; + + // NOTE: 수정 취소 + const handleCancel = () => { + form.reset(); + onEditComplete(); + }; + + return ( +
+ + ( + + +
+