Skip to content

Commit 1c38b75

Browse files
authored
Merge pull request #65 from B2A5/fix/freeboard-detail-page-refactor-ssr
[Fix]자유 게시판 상세 페이지 ssr기반으로 리팩토링
2 parents 2acc606 + c9de125 commit 1c38b75

File tree

54 files changed

+3403
-631
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+3403
-631
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
3+
import { Header } from '@/components/header/Header';
4+
import FreeboardDetail from './components/FreeboardDetail';
5+
import CommentList from './components/CommentList';
6+
import CommentInput from './components/CommentInput';
7+
import FreeboardDetailSkeleton from './components/FreeboardDetailSkeleton';
8+
import { useGetFreeboardPost } from '@/generated/api/endpoints/freeboard/freeboard';
9+
import { useAuthRestore } from '@/hooks/useAuth';
10+
11+
/**
12+
* 자유 게시판 게시글 상세 클라이언트 화면
13+
* @param postId 게시글 ID
14+
*/
15+
export default function ClientPage({ postId }: { postId: number }) {
16+
const { isAuthenticated } = useAuthRestore();
17+
const {
18+
data: post,
19+
isPending,
20+
error,
21+
} = useGetFreeboardPost(postId, {
22+
query: {
23+
staleTime: 0, // 언제나 신선하지 않은 것으로 간주
24+
gcTime: 5_000, // 화면 이탈 시 빠르게 캐시 정리
25+
refetchOnMount: isAuthenticated ? 'always' : false, // 로그인 사용자만 항상 최신화
26+
refetchOnWindowFocus: true, // 뒤로가기/포커스 전환 시 최신화
27+
refetchOnReconnect: true,
28+
},
29+
});
30+
31+
// TODO: 로딩/에러 처리 구체화 필요
32+
// postId가 유효하지 않은 경우 처리 필요
33+
if (!postId) return <FreeboardDetailSkeleton />;
34+
if (isPending) return <FreeboardDetailSkeleton />;
35+
if (error || !post) return <div>에러가 발생했습니다.</div>;
36+
37+
return (
38+
<main className="space-y-6 pt-12">
39+
<Header className="fixed top-0 left-0 right-0 z-50 bg-white">
40+
<Header.Left>
41+
<Header.BackButton /> {/* router.back() 내부 처리 */}
42+
</Header.Left>
43+
<Header.Center>자유게시판</Header.Center>
44+
<Header.Right>
45+
{/* TODO: onClick 핸들러 추가 */}
46+
<Header.MenuButton
47+
onClick={() => {
48+
/* 바텀시트 열기 */
49+
}}
50+
/>
51+
</Header.Right>
52+
</Header>
53+
54+
<FreeboardDetail post={post} />
55+
56+
<section className="px-5 pb-6">
57+
<CommentList
58+
postId={postId}
59+
initialCount={post.commentCount}
60+
/>
61+
<div className="fixed bottom-16 left-0 right-0 z-50 px-5 py-3">
62+
<CommentInput postId={postId} />
63+
</div>
64+
</section>
65+
66+
{/* safe-area 보정 */}
67+
<div className="fixed inset-x-0 bottom-16 z-50 bg-transparent">
68+
<div className="backdrop-blur-[2px] bg-white/90 w-full h-full absolute top-0 z-[-1]" />
69+
<div className="h-[env(safe-area-inset-bottom)]" />
70+
</div>
71+
</main>
72+
);
73+
}

apps/web/src/app/main/community/freeboard/[freeboardId]/components/CommentInput.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
'use client';
22

33
import {
4-
createComment,
5-
getGetCommentsByCursorQueryKey,
4+
createFreeboardComment,
5+
getGetFreeboardCommentsByCursorQueryKey,
66
} from '@/generated/api/endpoints/freeboard-comment/freeboard-comment';
77
import { useToast } from '@/hooks/ui/useToast';
88
import { useMutation, useQueryClient } from '@tanstack/react-query';
99
import React, { useEffect, useRef, useState } from 'react';
1010
import { twMerge } from 'tailwind-merge';
11+
import { useAuthGuard } from '@/hooks/useAuth';
1112

1213
interface CommentInputProps {
1314
/** 댓글이 달릴 게시글 ID */
@@ -31,15 +32,16 @@ export default function CommentInput({
3132
const targetRef = useRef<HTMLTextAreaElement>(null);
3233
const queryClient = useQueryClient();
3334
const toast = useToast();
35+
const { guard } = useAuthGuard();
3436

3537
const { mutate, isPending } = useMutation({
3638
mutationFn: (content: string) =>
37-
createComment(postId, { content }),
39+
createFreeboardComment(postId, { content }),
3840
onSuccess: () => {
3941
toast('댓글이 등록되었습니다', 'success');
4042
setValue('');
4143
queryClient.invalidateQueries({
42-
queryKey: getGetCommentsByCursorQueryKey(postId),
44+
queryKey: getGetFreeboardCommentsByCursorQueryKey(postId),
4345
});
4446
},
4547
onError: () => {
@@ -65,10 +67,11 @@ export default function CommentInput({
6567
setValue(next.length > limit ? next.slice(0, limit) : next);
6668
};
6769

68-
const handleSubmit = () => {
69-
if (!value.trim() || isPending) return;
70-
mutate(value.trim());
71-
};
70+
const handleSubmit = () =>
71+
guard(() => {
72+
if (!value.trim() || isPending) return;
73+
mutate(value.trim());
74+
});
7275

7376
const handleKeyDown = (
7477
e: React.KeyboardEvent<HTMLTextAreaElement>,

apps/web/src/app/main/community/freeboard/[freeboardId]/components/CommentItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ export default function CommentItem({
8989
<LikeButtonComment
9090
postId={postId ?? 0}
9191
commentId={commentId ?? 0}
92-
isLiked={isLiked ?? false}
93-
likeCount={likeCount ?? 0}
92+
initialLiked={isLiked ?? false}
93+
initialLikeCount={likeCount ?? 0}
9494
/>
9595
<span>{relativeTime(createdAt ?? '')}</span>
9696
</div>

apps/web/src/app/main/community/freeboard/[freeboardId]/components/CommentList.tsx

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,26 @@
22

33
import { useMemo } from 'react';
44
import { useInfiniteQuery } from '@tanstack/react-query';
5-
import {
6-
getCommentsByCursor,
7-
getGetCommentsByCursorQueryKey,
8-
} from '@/generated/api/endpoints/freeboard-comment/freeboard-comment';
95
import type { FreeboardCommentSummary } from '@/generated/api/models';
106
import { InfiniteScroll } from '@/components/infiniteScrolls/InfiniteScroll';
117
import CommentItem from './CommentItem';
128
import Skeleton from '@/components/loadings/Skeleton';
139
import { cn } from '@/utils/cn';
1410
import { formatCappedCount } from '@/utils/formatCount';
11+
import {
12+
getFreeboardCommentsByCursor,
13+
getGetFreeboardCommentsByCursorQueryKey,
14+
} from '@/generated/api/endpoints/freeboard-comment/freeboard-comment';
1515

1616
interface CommentListProps {
1717
postId: number;
18+
initialCount?: number;
1819
}
1920

20-
/**
21-
* 댓글 리스트
22-
* TODO: 백엔드 댓글 총 개수 제공 시 헤더에 추가 예정
23-
*/
24-
export default function CommentList({ postId }: CommentListProps) {
21+
export default function CommentList({
22+
postId,
23+
initialCount,
24+
}: CommentListProps) {
2525
const {
2626
data,
2727
fetchNextPage,
@@ -31,9 +31,9 @@ export default function CommentList({ postId }: CommentListProps) {
3131
error,
3232
refetch,
3333
} = useInfiniteQuery({
34-
queryKey: getGetCommentsByCursorQueryKey(postId),
34+
queryKey: getGetFreeboardCommentsByCursorQueryKey(postId),
3535
queryFn: ({ pageParam, signal }) =>
36-
getCommentsByCursor(
36+
getFreeboardCommentsByCursor(
3737
postId,
3838
{ cursor: pageParam, size: 10, sort: 'LATEST' },
3939
signal,
@@ -43,18 +43,13 @@ export default function CommentList({ postId }: CommentListProps) {
4343
lastPage.nextCursor ? lastPage.nextCursor : undefined,
4444
});
4545

46-
// 페이지 단위로 내려오는 comments를
47-
// 1. 모두 합치고(flatten)
48-
// 2. key로 쓸 수 있도록 commentId가 확실한 항목만 남김
49-
// 3. data가 바뀔 때에만 재계산(성능)
5046
const comments = useMemo<
5147
Array<FreeboardCommentSummary & { commentId: number }>
5248
>(() => {
5349
const allPages = data?.pages ?? [];
5450
const allComments = allPages.flatMap(
5551
(page) => page.comments ?? [],
5652
);
57-
5853
return allComments.filter(
5954
(
6055
comment,
@@ -63,6 +58,13 @@ export default function CommentList({ postId }: CommentListProps) {
6358
);
6459
}, [data]);
6560

61+
const latestTotal =
62+
data?.pages && data.pages.length > 0
63+
? data.pages[data.pages.length - 1]?.total
64+
: undefined;
65+
66+
const headerCount = latestTotal ?? initialCount ?? comments.length;
67+
6668
return (
6769
<section aria-label="댓글 섹션" className="flex-1">
6870
<h2 id="comments-heading" className="sr-only">
@@ -71,7 +73,7 @@ export default function CommentList({ postId }: CommentListProps) {
7173
<p className="pb-2" aria-live="polite">
7274
댓글
7375
<span className="text-soso-600 pl-1 font-medium">
74-
{formatCappedCount(comments.length)}
76+
{formatCappedCount(headerCount)}
7577
</span>
7678
7779
</p>

apps/web/src/app/main/community/freeboard/[freeboardId]/components/FreeboardDetail.tsx

Lines changed: 39 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,128 +5,87 @@ import ImageSlider from '@/components/ImageSlider';
55
import { UserProfile } from './UserProfile';
66
import { UserTypeBadge } from './UserTypeBadge';
77
import { relativeTime } from '@/utils/relativeTime';
8-
import { getGetPostQueryOptions } from '@/generated/api/endpoints/freeboard/freeboard';
98
import type { FreeboardDetailResponse } from '@/generated/api/models';
109
import LikeButtonPost from './LikeButtonPost';
1110
import { CategoryChip } from '@/components/chips/CategoryChip';
1211
import { Category } from '../../../constants/categories';
13-
import {
14-
QueryFunction,
15-
useSuspenseQuery,
16-
} from '@tanstack/react-query';
1712
import { formatCappedCount } from '@/utils/formatCount';
1813

19-
/** 자유게시판 게시글 상세 본문 */
14+
/**
15+
* 자유 게시판 게시글 상세 컴포넌트
16+
*
17+
* @param post 게시글 상세 데이터
18+
*/
2019
export default function FreeboardDetail({
21-
postId,
20+
post,
2221
}: {
23-
postId: number;
22+
post: FreeboardDetailResponse;
2423
}) {
25-
// 1) orval이 만든 옵션 팩토리에서 queryKey/queryFn 받기
26-
const { queryKey, queryFn } =
27-
getGetPostQueryOptions<FreeboardDetailResponse>(postId);
28-
29-
// 2) Suspense 전용 훅 사용 (로딩은 상위 <Suspense fallback>이 담당)
30-
const { data: post } = useSuspenseQuery({
31-
queryKey,
32-
queryFn: queryFn as QueryFunction<FreeboardDetailResponse>,
33-
staleTime: 1000 * 60 * 3,
34-
refetchOnMount: false,
35-
refetchOnWindowFocus: false,
36-
refetchOnReconnect: false,
37-
});
38-
39-
// 여기부터는 post가 보장됨 (optional chaining 남발 X)
40-
const author = post.author;
41-
const images = post.images ?? [];
42-
const imagesUrl = images.map((image) => image.imageUrl);
43-
const viewCount = post.viewCount ?? 0;
44-
const category = post.category ?? '';
45-
const title = post.title ?? '';
46-
const content = post.content ?? '';
47-
const createdAt = post.createdAt ?? '';
48-
49-
const hasAddress = Boolean(author?.address);
50-
const hasTime = Boolean(createdAt);
51-
const hasMetaInfo = hasAddress || hasTime;
24+
const { author } = post;
5225

5326
return (
5427
<div className="p-5 border-b border-neutral-0">
5528
{/* 카테고리 */}
56-
{category && (
57-
<div className="flex items-center gap-1 pb-2">
58-
<CategoryChip category={category as Category} />
59-
</div>
60-
)}
29+
<div className="flex items-center gap-1 pb-2">
30+
<CategoryChip
31+
category={post.category as unknown as Category}
32+
/>
33+
</div>
6134

62-
{/* 작성자 정보 */}
35+
{/* 작성자 */}
6336
<UserProfile className="items-start pb-6">
6437
<UserProfile.Left>
6538
<UserProfile.Avatar
66-
url={author?.profileImageUrl}
39+
url={author.profileImageUrl}
6740
size={45}
68-
alt={`${author?.nickname ?? '사용자'}의 프로필 이미지`}
41+
alt={`${author.nickname}의 프로필 이미지`}
6942
/>
7043
</UserProfile.Left>
7144

7245
<UserProfile.Right>
7346
<UserProfile.Name
74-
nickname={author?.nickname ?? ''}
75-
userType={
76-
author?.userType && (
77-
<UserTypeBadge type={author.userType} />
78-
)
79-
}
47+
nickname={author.nickname}
48+
userType={<UserTypeBadge type={author.userType} />}
8049
/>
81-
82-
{hasMetaInfo && (
83-
<UserProfile.SubContents>
84-
<div className="text-input2 text-neutral-500">
85-
{hasAddress && <span>{author?.address}</span>}
86-
{hasAddress && hasTime && (
87-
<span className="mx-1">·</span>
88-
)}
89-
{hasTime && (
90-
<span suppressHydrationWarning>
91-
{relativeTime(createdAt)}
92-
</span>
93-
)}
94-
</div>
95-
</UserProfile.SubContents>
96-
)}
50+
<UserProfile.SubContents>
51+
<div className="text-input2 text-neutral-500">
52+
<span>{author.address}</span>
53+
<span className="mx-1">·</span>
54+
<span>{relativeTime(post.createdAt)}</span>
55+
</div>
56+
</UserProfile.SubContents>
9757
</UserProfile.Right>
9858
</UserProfile>
9959

10060
{/* 본문 */}
10161
<div className="flex flex-col space-y-2 pb-6">
102-
<h1 className="text-2xl font-bold">Q. {title}</h1>
62+
<h1 className="text-2xl font-bold">Q. {post.title}</h1>
10363

104-
{images.length > 0 && (
64+
{post.images.length > 0 && (
10565
<ImageSlider
106-
images={imagesUrl}
66+
images={post.images.map((img) => img.imageUrl)}
10767
className="w-full min-h-[200px]"
10868
/>
10969
)}
11070

111-
{content && (
112-
<p className="text-textBox text-neutral-1000">{content}</p>
71+
{post.content && (
72+
<p className="text-textBox text-neutral-1000">
73+
{post.content}
74+
</p>
11375
)}
11476
</div>
115-
{/* 하단 좋아요 + 조회수 */}
77+
78+
{/* 하단 */}
11679
<div className="flex justify-between items-center">
11780
<LikeButtonPost
118-
postId={postId}
119-
isLiked={post?.isLiked ?? false}
120-
likeCount={post?.likeCount ?? 0}
81+
postId={post.postId}
82+
initialLiked={post.isLiked}
83+
initialLikeCount={post.likeCount}
12184
/>
122-
12385
<div className="flex items-center gap-1.5">
12486
<Eye className="inline w-6 h-6 text-neutral-200" />
125-
<span
126-
suppressHydrationWarning
127-
className="text-neutral-500 text-input2"
128-
>
129-
{formatCappedCount(viewCount)}
87+
<span className="text-neutral-500 text-input2">
88+
{formatCappedCount(post.viewCount)}
13089
</span>
13190
</div>
13291
</div>

0 commit comments

Comments
 (0)