Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 상세페이지 댓글 조회 api연동 #38

Merged
merged 12 commits into from
Jul 18, 2024
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 2 additions & 9 deletions src/apis/epigram.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,36 @@
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<GetEpigramResponseType> => {
const { id } = request;

// id가 undefined인 경우 에러 throw
if (id === undefined) {
throw new Error('Epigram ID가 제공되지 않았습니다.');
}

// NOTE : 임시로 테스트계정의 토큰을 변수로 선언해서 사용

try {
const response = await axios.get(`${BASE_URL}/epigrams/${id}`, {
const response = await httpClient.get(`/epigrams/${id}`, {
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
});
return response.data;
} catch (error) {
// 에러가 Axios에러인지 확인
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
// 서버에 요청 성공, 응답으로 200번대 이외의 상태를 응답으로 받았을때
throw new Error(`API 에러: ${axiosError.response.status}`);
} else if (axiosError.request) {
// 요청이 이루어졌으나 응답을 받지 못한 경우
throw new Error('서버로부터 응답을 받지 못했습니다.');
} else {
// 요청 설정 중에 오류가 발생한 경우
throw new Error('요청 설정 중 오류가 발생했습니다.');
}
} else {
// axios 에러가 아닌 경우
throw new Error('예상치 못한 오류가 발생했습니다.');
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/apis/epigramComment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import httpClient from '@/apis/index';
import { CommentRequestSchema, CommentRequestType, CommentResponseSchema, CommentResponseType } from '@/schema/comment';

const getEpigramComments = async (params: CommentRequestType): Promise<CommentResponseType> => {
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<CommentResponseType>(`/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;
8 changes: 8 additions & 0 deletions src/apis/queries.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -28,6 +30,12 @@ const queries = createQueryKeyStore({
enabled: request.id !== undefined,
}),
},
epigramComment: {
getComments: (request: CommentRequestType) => ({
queryKey: ['epigramComments', request],
queryFn: () => getEpigramComments(request),
}),
},
});

export default queries;
49 changes: 49 additions & 0 deletions src/components/epigram/Comment/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`bg-slate-100 border-t border-slate-300 flex-col justify-start items-start gap-2.5 inline-flex ${sizeStyles.sm} ${sizeStyles.md} ${sizeStyles.lg} ${paddingStyles.sm} ${paddingStyles.md} ${paddingStyles.lg}`}
>
<div className='justify-start items-start gap-4 inline-flex'>
<div className='w-12 h-12 relative'>
<div className='w-12 h-12 bg-zinc-300 rounded-full overflow-hidden flex items-center justify-center'>
<Image src={comment.writer.image || '/ProfileTestImage.jpg'} alt='프로필 이미지' layout='fill' objectFit='cover' className='rounded-full' />
</div>
</div>
<div className={`flex-col justify-start items-start ${gapStyles.sm} ${gapStyles.md} ${gapStyles.lg} inline-flex ${contentWidthStyles.sm} ${contentWidthStyles.md} ${contentWidthStyles.lg}`}>
<div className='justify-between items-center w-full inline-flex'>
<div className='justify-start items-start gap-2 flex'>
<div className={`text-zinc-600 font-normal font-pretendard leading-normal ${textSizeStyles.sm.name} ${textSizeStyles.md.name} ${textSizeStyles.lg.name}`}>{comment.writer.nickname}</div>
<div className={`text-zinc-600 font-normal font-pretendard leading-normal ${textSizeStyles.sm.time} ${textSizeStyles.md.time} ${textSizeStyles.lg.time}`}>
{getCustomRelativeTime(comment.createdAt)}
</div>
</div>
{status === 'edit' && (
<div className='justify-start items-start gap-4 flex'>
<div className={`text-neutral-700 underline leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}>수정</div>
<div className={`text-red-400 underline leading-[18px] cursor-pointer ${textSizeStyles.sm.action} ${textSizeStyles.md.action} ${textSizeStyles.lg.action}`}>삭제</div>
</div>
)}
</div>
<div
className={`w-full text-zinc-800 font-normal font-pretendard ${textSizeStyles.sm.content} ${textSizeStyles.md.content} ${textSizeStyles.lg.content} ${contentWidthStyles.sm} ${contentWidthStyles.md} ${contentWidthStyles.lg}`}
>
{comment.content}
</div>
</div>
</div>
</div>
);
}

export default CommentItem;
68 changes: 68 additions & 0 deletions src/components/epigram/Comment/CommentList.tsx
Original file line number Diff line number Diff line change
@@ -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<IntersectionObserver | null>(null);
const lastCommentRef = useRef<HTMLDivElement | null>(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 <div>댓글을 불러오는 중...</div>;
if (status === 'error') return <div>에러: 댓글을 불러오는데 실패했습니다.</div>;

const allComments = data?.pages.flatMap((page) => page.list) || [];
const totalCount = data?.pages[0]?.totalCount || 0;

return (
<div className='flex flex-col gap-4'>
<h3 className='text-base lg:text-xl font-semibold'>댓글({totalCount})</h3>
{allComments.length > 0 ? (
<>
{allComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} status={comment.writer.id === currentUserId ? 'edit' : 'complete'} />
))}
<div ref={lastCommentRef}>{isFetchingNextPage && <div>더 많은 댓글을 불러오는 중...</div>}</div>
</>
) : (
<NoComment />
)}
</div>
);
}

export default CommentList;
9 changes: 9 additions & 0 deletions src/components/epigram/Comment/NoComment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { sizeStyles } from '@/styles/CommentCardStyles';

export default function NoComment() {
return (
<div className={`flex justify-center ${sizeStyles.sm} ${sizeStyles.md} ${sizeStyles.lg}`}>
<p className='text-xl'>댓글을 작성해보세요!</p>
</div>
);
}
14 changes: 14 additions & 0 deletions src/hooks/useEpigramCommentsQueryHook.ts
Original file line number Diff line number Diff line change
@@ -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<CommentResponseType, Error, InfiniteData<CommentResponseType>, [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;
File renamed without changes.
2 changes: 1 addition & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjIsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoiYWNjZXNzIiwiaWF0IjoxNzIwODc2OTg5LCJleHAiOjE3MjA4Nzg3ODksImlzcyI6InNwLWVwaWdyYW0ifQ.cKo4iplziBvVxnCVAYi0rA213R-6w2iHCu-Z9bukUhA';
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjIsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoiYWNjZXNzIiwiaWF0IjoxNzIxMjAwMTI0LCJleHAiOjE3MjEyMDE5MjQsImlzcyI6InNwLWVwaWdyYW0ifQ.BUQ4EAR04HMIg5YMAJI8Rd7aa6_GyAoZs89YL0TuCgg';

export default TOKEN;
28 changes: 28 additions & 0 deletions src/lib/dateUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 10 additions & 6 deletions src/pageLayout/Epigram/EpigramComment.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='bg-background-100 flex justify-center h-[500px]'>
<div className='w-80 md:w-96 lg:w-[640px] pt-6 lg:pt-12 pb-36'>
<div className='flex flex-col gap-4 lg:gap-6'>
<h3 className='text-base lg:text-xl font-semibold'>댓글(3)</h3>
<div className='bg-slate-100 flex justify-center '>
<div className='w-80 md:w-96 lg:w-[640px] pt-6 lg:pt-12'>
<h3 className='text-base lg:text-xl font-semibold'>댓글 작성</h3>
<div className={`flex flex-col gap-4 lg:gap-6 ${paddingStyles.sm} ${paddingStyles.md} ${paddingStyles.lg}`}>
<div className='flex gap-4 lg:gap-6'>
<div className='w-12 h-12'>
<Image src='/profile.svg' alt='프로필 사진' width={48} height={48} />
</div>
<Textarea
className='bg-background-100 w-full text-base lg:text-xl text-black p-4 border-solid border-line-200 border-2 rounded-lg resize-none focus-visible:ring-0'
className='bg-slate-100 w-full text-base lg:text-xl text-black p-4 border-solid border-line-200 border-2 rounded-lg resize-none focus-visible:ring-0'
placeholder='100자 이내로 입력해 주세요.'
/>
</div>
</div>
<CommentList epigramId={epigramId} currentUserId={currentUserId} />
</div>
</div>
);
Expand Down
7 changes: 4 additions & 3 deletions src/pages/epigram/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GetEpigramRequestSchema } from '@/schema/epigram';
import useEpigramQuery from '@/hooks/epigramQueryHook';
import CommentSection from '@/pageLayout/Epigram/EpigramComment';
import useEpigramQuery from '@/hooks/useEpigramQueryHook';
import EpigramComment from '@/pageLayout/Epigram/EpigramComment';
import EpigramFigure from '@/pageLayout/Epigram/EpigramFigure';
import Image from 'next/image';
import { useRouter } from 'next/router';
Expand All @@ -27,7 +27,8 @@ function DetailPage() {
<Image src='/logo.svg' alt='Epigram 로고' width={172} height={48} />
<Image src='/share.svg' alt='공유 버튼' width={36} height={36} />
</nav>
<EpigramFigure epigram={epigram} currentUserId={userData?.id} /> <CommentSection />
<EpigramFigure epigram={epigram} currentUserId={userData?.id} />
<EpigramComment epigramId={epigram.id} currentUserId={userData?.id} />
</div>
);
}
Expand Down
Loading
Loading