Skip to content

Commit a75d485

Browse files
authored
Feat/comment#22 (#99)
* [feat] 댓글 기능 * 잠깐 주석 * 댓글기능 * 댓글 기능 * 댓글 무한스크롤 최적화 * 리스트크기 동적변경, 스크롤이벤트추가
1 parent 172b261 commit a75d485

File tree

25 files changed

+841
-86
lines changed

25 files changed

+841
-86
lines changed

package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
]
1919
},
2020
"dependencies": {
21+
"@tanstack/react-virtual": "^3.13.12",
2122
"class-variance-authority": "^0.7.1",
2223
"gsap": "^3.13.0",
2324
"lottie-react": "^2.4.1",

src/app/community/[id]/loading.tsx

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,7 @@
1-
function Loading() {
2-
return (
3-
<div className="w-full mb-10 flex relative animate-pulse">
4-
{/* 메인 콘텐츠 */}
5-
<article className="page-layout max-w-[824px] flex-1 z-5 space-y-6 mt-15">
6-
{/* DetailHeader 자리 */}
7-
<div className="h-6 w-15 bg-gray rounded-md" />
8-
9-
{/* Title 자리 */}
10-
<div className="h-12 w-full bg-gray rounded-md" />
11-
<div className="h-7 w-20 -mt-2 bg-gray rounded-md" />
1+
import DetailSkeleton from '@/domains/community/detail/DetailSkeleton';
122

13-
{/* Content 자리 */}
14-
<div className="space-y-2 mt-5">
15-
<div className="h-70 w-full bg-gray rounded-md" />
16-
</div>
17-
18-
{/* 댓글 */}
19-
<div className="h-9 w-full bg-gray rounded-md mt-4" />
20-
<div className="space-y-3">
21-
{[...Array(2)].map((_, i) => (
22-
<div key={i} className="h-16 w-full bg-gray rounded-md" />
23-
))}
24-
</div>
25-
</article>
26-
</div>
27-
);
3+
function Loading() {
4+
return <DetailSkeleton />;
285
}
296

307
export default Loading;

src/app/community/[id]/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,28 @@ import Comment from '@/domains/community/detail/Comment';
1010
import StarBg from '@/domains/shared/components/star-bg/StarBg';
1111
import { useParams } from 'next/navigation';
1212
import { useEffect, useState } from 'react';
13+
import DetailSkeleton from '@/domains/community/detail/DetailSkeleton';
1314

1415
function Page() {
1516
const params = useParams();
1617
const [postDetail, setPostDetail] = useState<Post | null>(null);
18+
const [isLoading, setIsLoading] = useState(false);
1719

1820
useEffect(() => {
1921
const postId = params.id;
2022
const fetchData = async () => {
23+
setIsLoading(true);
2124
const data = await fetchPostById(postId);
2225
if (!data) return;
2326

2427
setPostDetail(data);
28+
setIsLoading(false);
2529
};
2630
fetchData();
2731
}, [params.id, setPostDetail]);
2832

29-
if (!postDetail) return <div>게시글을 불러오지 못했습니다.</div>;
33+
if (isLoading) return <DetailSkeleton />;
34+
if (!postDetail) return null;
3035

3136
const {
3237
categoryName,

src/domains/community/api/fetchComment.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,84 @@ export const fetchComment = async (postId: number): Promise<CommentType[] | null
55
try {
66
const res = await fetch(`${getApi}/posts/${postId}/comments`, {
77
method: 'GET',
8+
cache: 'no-store', // 캐시 비활성화
89
});
910
const data = await res.json();
10-
return data.data;
11+
12+
//삭제된 댓글은 제외
13+
const filteredComments = data.data.filter(
14+
(comment: CommentType) => comment.status !== 'DELETED'
15+
);
16+
17+
return filteredComments;
1118
} catch (err) {
1219
console.error('해당 글의 댓글 조회 실패', err);
1320
return null;
1421
}
1522
};
23+
24+
export const postComments = async (postId: number, content: string) => {
25+
try {
26+
const res = await fetch(`${getApi}/posts/${postId}/comments`, {
27+
method: 'POST',
28+
headers: {
29+
'Content-Type': 'application/json',
30+
},
31+
credentials: 'include',
32+
body: JSON.stringify({ content }),
33+
});
34+
35+
const text = await res.text();
36+
37+
if (!res.ok) {
38+
console.error(`댓글 작성 실패: ${res.status}`, text);
39+
return null;
40+
}
41+
const data = JSON.parse(text);
42+
return data;
43+
} catch (err) {
44+
console.error('해당 글의 댓글 작성 실패', err);
45+
return null;
46+
}
47+
};
48+
49+
export async function updateComment(
50+
accessToken: string | null,
51+
postId: number,
52+
commentId: number,
53+
content: string
54+
): Promise<void> {
55+
const response = await fetch(`${getApi}/posts/${postId}/comments/${commentId}`, {
56+
method: 'PATCH',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
Authorization: `Bearer ${accessToken}`,
60+
},
61+
body: JSON.stringify({ content }),
62+
});
63+
64+
if (!response.ok) {
65+
const errorText = await response.text(); // 👈 응답 본문을 텍스트로 읽기
66+
console.error('서버 응답 에러:', errorText);
67+
throw new Error(`댓글 수정 실패: ${response.status}`);
68+
}
69+
}
70+
71+
export async function deleteComment(
72+
accessToken: string | null,
73+
postId: number,
74+
commentId: number
75+
): Promise<void> {
76+
const response = await fetch(`${getApi}/posts/${postId}/comments/${commentId}`, {
77+
method: 'DELETE',
78+
headers: {
79+
Authorization: `Bearer ${accessToken}`,
80+
},
81+
});
82+
83+
if (!response.ok) {
84+
const errorText = await response.text(); // 👈 응답 본문을 텍스트로 읽기
85+
console.error('서버 응답 에러:', errorText);
86+
throw new Error(`댓글 수정 실패: ${response.status}`);
87+
}
88+
}

src/domains/community/api/fetchPost.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ParamValue } from 'next/dist/server/request/params';
44

55
export const fetchPost = async (): Promise<Post[] | null> => {
66
try {
7-
const res = await fetch(`${getApi}/posts`, {
7+
const res = await fetch(`${getApi}/posts?postSortStatus=LATEST`, {
88
method: 'GET',
99
cache: 'no-store',
1010
});

src/domains/community/api/fetchView.ts

Whitespace-only changes.

src/domains/community/components/post-info/PostInfo.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ function PostInfo({
2828
)}
2929
<li>{elapsedTime(createdAt)}</li>
3030
<li aria-hidden="true">|</li>
31-
<li>조회 {viewCount}</li>
31+
<li>조회 {viewCount || 0}</li>
3232
<li aria-hidden="true">|</li>
33-
<li>댓글 {commentCount}</li>
33+
<li>댓글 {commentCount + 1 || 0}</li>
3434
</ul>
3535
);
3636
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Virtualizer } from '@tanstack/react-virtual';
2+
import gsap from 'gsap';
3+
import { useEffect, useRef } from 'react';
4+
5+
type Props = {
6+
value: string;
7+
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
8+
rowVirtualize: Virtualizer<HTMLElement, Element>;
9+
};
10+
11+
function AutoGrowingTextarea({ value, onChange, rowVirtualize }: Props) {
12+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
13+
14+
useEffect(() => {
15+
const textarea = textareaRef.current;
16+
if (!textarea) return;
17+
18+
textarea.style.height = 'auto'; // 높이 초기화
19+
textarea.style.height = textarea.scrollHeight + 'px'; // 스크롤 높이만큼 늘리기
20+
21+
const handleInput = () => {
22+
textarea.style.height = 'auto';
23+
textarea.style.height = textarea.scrollHeight + 'px';
24+
};
25+
26+
textarea.addEventListener('input', handleInput);
27+
return () => {
28+
textarea.removeEventListener('input', handleInput);
29+
};
30+
}, []);
31+
32+
useEffect(() => {
33+
if (textareaRef.current) {
34+
requestAnimationFrame(() => {
35+
const li = textareaRef.current?.closest('li') as HTMLElement | null;
36+
if (li) {
37+
rowVirtualize.measureElement(li);
38+
}
39+
});
40+
}
41+
}, [value]);
42+
43+
useEffect(() => {
44+
if (!textareaRef.current) return;
45+
gsap.fromTo(
46+
textareaRef.current,
47+
{ autoAlpha: 0, y: -15 },
48+
{ duration: 0.4, autoAlpha: 1, y: 0, ease: 'power2.out' }
49+
);
50+
}, []);
51+
52+
return (
53+
<textarea
54+
ref={textareaRef}
55+
value={value}
56+
className="w-full resize-none overflow-hidden bg-white py-2 px-4 text-primary rounded-lg outline-none"
57+
rows={1}
58+
spellCheck={false}
59+
onChange={(e) => onChange(e)}
60+
/>
61+
);
62+
}
63+
64+
export default AutoGrowingTextarea;

src/domains/community/detail/Comment.tsx

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
1-
import React, { useEffect, useState } from 'react';
21
import CommentHeader from '../../shared/components/comment/CommentHeader';
32
import CommentList from '../../shared/components/comment/CommentList';
4-
import { fetchComment } from '../api/fetchComment';
5-
import { CommentType } from '../types/post';
3+
import { postComments } from '../api/fetchComment';
4+
import { useAuthStore } from '@/domains/shared/store/auth';
5+
import { useShallow } from 'zustand/shallow';
6+
import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal';
7+
import { useComments } from '../hook/useComment';
68

79
type Props = {
810
postId: number;
911
};
1012

1113
function Comment({ postId }: Props) {
12-
const [comments, setComments] = useState<CommentType[] | null>(null);
13-
14-
useEffect(() => {
15-
const fetchData = async () => {
16-
const data = await fetchComment(postId);
17-
if (!data) return;
18-
setComments(data);
19-
};
20-
fetchData();
21-
}, [postId]);
14+
const { user, accessToken } = useAuthStore(
15+
useShallow((state) => ({
16+
user: state.user,
17+
accessToken: state.accessToken,
18+
}))
19+
);
20+
const {
21+
comments,
22+
isEnd,
23+
isLoading,
24+
deleteTarget,
25+
setDeleteTarget,
26+
fetchData,
27+
handleUpdateComment,
28+
handleAskDeleteComment,
29+
handleConfirmDelete,
30+
loadMoreComments,
31+
} = useComments(postId, user, accessToken);
2232

2333
return (
24-
<section className="mb-10 border-t-1 border-gray ">
25-
<CommentHeader />
26-
<CommentList comments={comments} />
27-
</section>
34+
<>
35+
<section className="mb-10 border-t-1 border-gray ">
36+
<CommentHeader
37+
postId={postId}
38+
comments={comments}
39+
onCommentAdded={fetchData}
40+
postCommentsApi={postComments}
41+
/>
42+
<CommentList
43+
comments={comments}
44+
currentUserNickname={user?.nickname}
45+
onUpdateComment={handleUpdateComment}
46+
onDeleteComment={handleAskDeleteComment}
47+
onLoadMore={loadMoreComments}
48+
isEnd={isEnd}
49+
isLoading={isLoading}
50+
/>
51+
</section>
52+
{deleteTarget && (
53+
<ConfirmModal
54+
open={!!deleteTarget}
55+
onConfirm={handleConfirmDelete}
56+
onCancel={() => setDeleteTarget(null)}
57+
onClose={() => setDeleteTarget(null)}
58+
title="댓글 삭제"
59+
description="정말 이 댓글을 삭제하시겠습니까?"
60+
/>
61+
)}
62+
</>
2863
);
2964
}
3065

0 commit comments

Comments
 (0)