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 ( +
+ + ( + + +
+