diff --git a/package-lock.json b/package-lock.json index 561da0f2..054ac693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", @@ -2901,6 +2902,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index 66aa1a4e..2113a29d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", diff --git a/src/apis/epigram.ts b/src/apis/epigram.ts index d7b962df..2448efc3 100644 --- a/src/apis/epigram.ts +++ b/src/apis/epigram.ts @@ -1,13 +1,11 @@ import axios, { AxiosError } from 'axios'; import { GetEpigramResponseType, GetEpigramRequestType } from '@/schema/epigram'; import TOKEN from '@/lib/constants'; - -const BASE_URL = 'https://fe-project-epigram-api.vercel.app/5-9'; +import httpClient from '.'; const getEpigram = async (request: GetEpigramRequestType): Promise => { const { id } = request; - // id가 undefined인 경우 에러 throw if (id === undefined) { throw new Error('Epigram ID가 제공되지 않았습니다.'); } @@ -15,7 +13,7 @@ const getEpigram = async (request: GetEpigramRequestType): Promise => { + try { + // 요청 파라미터 유효성 검사 + const validatedParams = CommentRequestSchema.parse(params); + + const { id, limit, cursor } = validatedParams; + + // NOTE: URL의 쿼리 문자열을 사용 + // NOTE : cursor값이 있다면 ?limit=3&cursor=100, 없다면 ?limit=3,(숫자는 임의로 지정한 것) + const queryParams = new URLSearchParams({ + limit: limit.toString(), + ...(cursor !== undefined && { cursor: cursor.toString() }), + }); + + const response = await httpClient.get(`/epigrams/${id}/comments?${queryParams.toString()}`); + + // 응답 데이터 유효성 검사 + const validatedData = CommentResponseSchema.parse(response.data); + + return validatedData; + } catch (error) { + if (error instanceof Error) { + throw new Error(`댓글을 불러오는데 실패했습니다: ${error.message}`); + } + throw error; + } +}; + +export default getEpigramComments; diff --git a/src/apis/queries.ts b/src/apis/queries.ts index 9d0d23d8..1a0299ec 100644 --- a/src/apis/queries.ts +++ b/src/apis/queries.ts @@ -1,8 +1,10 @@ import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import { GetUserRequestType } from '@/schema/user'; import { GetEpigramRequestType } from '@/schema/epigram'; +import { CommentRequestType } from '@/schema/comment'; import { getMe, getUser } from './user'; import getEpigram from './epigram'; +import getEpigramComments from './epigramComment'; const queries = createQueryKeyStore({ user: { @@ -28,6 +30,12 @@ const queries = createQueryKeyStore({ enabled: request.id !== undefined, }), }, + epigramComment: { + getComments: (request: CommentRequestType) => ({ + queryKey: ['epigramComments', request], + queryFn: () => getEpigramComments(request), + }), + }, }); 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..194011bf --- /dev/null +++ b/src/components/epigram/Comment/CommentItem.tsx @@ -0,0 +1,49 @@ +import React 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 { CommentCardProps } from '@/types/CommentCardTypes'; + +interface CommentItemProps extends CommentCardProps { + comment: CommentType; +} + +function CommentItem({ comment, status }: CommentItemProps) { + 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..57908c58 --- /dev/null +++ b/src/components/epigram/Comment/CommentList.tsx @@ -0,0 +1,68 @@ +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'; + +function CommentList({ epigramId, currentUserId }: EpigramCommentProps) { + 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/NoComment.tsx b/src/components/epigram/Comment/NoComment.tsx new file mode 100644 index 00000000..def6acda --- /dev/null +++ b/src/components/epigram/Comment/NoComment.tsx @@ -0,0 +1,9 @@ +import { sizeStyles } from '@/styles/CommentCardStyles'; + +export default function NoComment() { + return ( +
+

댓글을 작성해보세요!

+
+ ); +} diff --git a/src/hooks/useEpigramCommentsQueryHook.ts b/src/hooks/useEpigramCommentsQueryHook.ts new file mode 100644 index 00000000..90202f99 --- /dev/null +++ b/src/hooks/useEpigramCommentsQueryHook.ts @@ -0,0 +1,14 @@ +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; +import { CommentResponseType } from '@/schema/comment'; +import getEpigramComments from '@/apis/epigramComment'; + +const useEpigramCommentsQuery = (epigramId: number) => + // NOTE: 순서대로 API응답타입, 에러타입, 반환되는데이터타입, 쿼리 키 타입, 페이지 파라미터의 타입 + useInfiniteQuery, [string, number], number | undefined>({ + queryKey: ['epigramComments', epigramId], + queryFn: ({ pageParam }) => getEpigramComments({ id: epigramId, limit: 3, cursor: pageParam }), + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + +export default useEpigramCommentsQuery; diff --git a/src/hooks/epigramQueryHook.ts b/src/hooks/useEpigramQueryHook.ts similarity index 100% rename from src/hooks/epigramQueryHook.ts rename to src/hooks/useEpigramQueryHook.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 415bd875..737859ce 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,4 @@ const TOKEN = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjIsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoiYWNjZXNzIiwiaWF0IjoxNzIwODc2OTg5LCJleHAiOjE3MjA4Nzg3ODksImlzcyI6InNwLWVwaWdyYW0ifQ.cKo4iplziBvVxnCVAYi0rA213R-6w2iHCu-Z9bukUhA'; + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjIsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoiYWNjZXNzIiwiaWF0IjoxNzIxMjAwMTI0LCJleHAiOjE3MjEyMDE5MjQsImlzcyI6InNwLWVwaWdyYW0ifQ.BUQ4EAR04HMIg5YMAJI8Rd7aa6_GyAoZs89YL0TuCgg'; export default TOKEN; diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 00000000..4b8eecc5 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,28 @@ +import { formatDistanceToNow, parseISO } from 'date-fns'; +import { ko } from 'date-fns/locale/ko'; + +/** + * @param date 날짜 문자열 또는 Date 객체 + * @return 시간을 나타내는 문자열 (예: "3일 전", "2시간 전") + */ +function getCustomRelativeTime(date: string | Date): string { + const dateToUse = typeof date === 'string' ? parseISO(date) : date; + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - dateToUse.getTime()) / 1000); + + if (diffInSeconds < 60) { + return '방금 전'; + } + if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}분 전`; + } + if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}시간 전`; + } + // 1일(date-fns에선 23시59분30초) 이상 차이나는 경우 date-fns의 formatDistanceToNow 사용 + return formatDistanceToNow(dateToUse, { addSuffix: true, locale: ko }); +} + +export default getCustomRelativeTime; diff --git a/src/pageLayout/Epigram/EpigramComment.tsx b/src/pageLayout/Epigram/EpigramComment.tsx index 230bb568..36d50ac3 100644 --- a/src/pageLayout/Epigram/EpigramComment.tsx +++ b/src/pageLayout/Epigram/EpigramComment.tsx @@ -1,22 +1,26 @@ +import CommentList from '@/components/epigram/Comment/CommentList'; import { Textarea } from '@/components/ui/textarea'; +import { paddingStyles } from '@/styles/CommentCardStyles'; +import { EpigramCommentProps } from '@/types/epigram.types'; import Image from 'next/image'; -function EpigramComment() { +function EpigramComment({ epigramId, currentUserId }: EpigramCommentProps) { return ( -
-
-
-

댓글(3)

+
+
+

댓글 작성

+
프로필 사진