Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions apps/web/src/apis/community/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import type {
} from "@/types/community";
import { axiosInstance, publicAxiosInstance } from "@/utils/axiosInstance";

// QueryKeys for community domain
export const CommunityQueryKeys = {
posts: "posts",
postList: "postList1", // 기존 api/boards와 동일한 키 유지
} as const;
export { CommunityQueryKeys } from "./queryKeys";

export interface BoardListResponse {
0: string;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/apis/community/deletePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const useDeletePost = () => {
// 'posts' 쿼리 키를 가진 모든 쿼리를 무효화하여
// 게시글 목록을 다시 불러오도록 합니다.
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] });
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.postList] });

// ISR 페이지 revalidate
if (variables.boardCode && accessToken) {
Expand Down
33 changes: 20 additions & 13 deletions apps/web/src/apis/community/getPostList.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { queryOptions, useQuery } from "@tanstack/react-query";

import { CommunityQueryKeys, communityApi } from "./api";
import { communityApi } from "./api";
import {
COMMUNITY_POST_LIST_GC_TIME,
COMMUNITY_POST_LIST_STALE_TIME,
communityPostListQueryKey,
sortCommunityPosts,
} from "./postListQuery";

interface UseGetPostListProps {
boardCode: string;
category?: string | null;
}

export const getPostListQueryOptions = ({ boardCode, category = null }: UseGetPostListProps) =>
queryOptions({
queryKey: communityPostListQueryKey(boardCode, category),
queryFn: async () => {
const response = await communityApi.getPostList(boardCode, category);
return sortCommunityPosts(response.data);
},
staleTime: COMMUNITY_POST_LIST_STALE_TIME,
gcTime: COMMUNITY_POST_LIST_GC_TIME,
});

/**
* @description 게시글 목록 조회 훅
*/
const useGetPostList = ({ boardCode, category = null }: UseGetPostListProps) => {
return useQuery({
queryKey: [CommunityQueryKeys.postList, boardCode, category],
queryFn: () => communityApi.getPostList(boardCode, category),
staleTime: Infinity,
gcTime: 1000 * 60 * 30, // 30분
select: (response) => {
return [...response.data].sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
},
});
return useQuery(getPostListQueryOptions({ boardCode, category }));
};

export default useGetPostList;
2 changes: 1 addition & 1 deletion apps/web/src/apis/community/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export { default as useDeletePost } from "./deletePost";
export { default as useGetBoard } from "./getBoard";
export { default as useGetBoardList } from "./getBoardList";
export { default as useGetPostDetail } from "./getPostDetail";
export { default as useGetPostList } from "./getPostList";
export { default as useGetPostList, getPostListQueryOptions } from "./getPostList";
export { default as usePatchUpdateComment } from "./patchUpdateComment";
export { default as useUpdatePost } from "./patchUpdatePost";
export { default as useCreateComment } from "./postCreateComment";
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/apis/community/patchUpdatePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const useUpdatePost = () => {
// 해당 게시글 상세 쿼리와 목록 쿼리를 무효화
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts, variables.postId] });
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] });
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.postList] });

// ISR 페이지 revalidate
if (variables.boardCode && accessToken) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/apis/community/postCreatePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const useCreatePost = () => {
onSuccess: async (data) => {
// 게시글 목록 쿼리를 무효화하여 최신 목록 반영
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.posts] });
queryClient.invalidateQueries({ queryKey: [CommunityQueryKeys.postList] });

// ISR 페이지 revalidate (사용자 인증 토큰 사용)
if (accessToken) {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/apis/community/postListQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ListPost } from "@/types/community";
import { CommunityQueryKeys } from "./queryKeys";

export const COMMUNITY_INITIAL_CATEGORY = "전체";
export const COMMUNITY_POST_LIST_STALE_TIME = Infinity;
export const COMMUNITY_POST_LIST_GC_TIME = 1000 * 60 * 30; // 30분

export const communityPostListQueryKey = (boardCode: string, category: string | null = null) =>
[CommunityQueryKeys.postList, boardCode, category] as const;
Comment on lines +8 to +9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

1. 전체null을 같은 캐시 키로 정규화해 주세요.

지금은 같은 “전체 목록” 요청이 ["postList1", boardCode, null]["postList1", boardCode, "전체"]로 나뉩니다. apps/web/src/apis/community/getPostList.ts Line 16은 기본값을 null로 두고, apps/web/src/app/community/[boardCode]/page.tsx Lines 44-48은 "전체"로 프리패치하고 있어서, 다른 호출부가 기본값을 쓰면 하이드레이션된 캐시를 재사용하지 못하고 한 번 더 요청하게 됩니다.

🔧 제안 코드
 export const COMMUNITY_INITIAL_CATEGORY = "전체";
 export const COMMUNITY_POST_LIST_STALE_TIME = Infinity;
 export const COMMUNITY_POST_LIST_GC_TIME = 1000 * 60 * 30; // 30분

+export const normalizeCommunityPostListCategory = (category: string | null = null) =>
+  !category || category === COMMUNITY_INITIAL_CATEGORY ? null : category;
+
 export const communityPostListQueryKey = (boardCode: string, category: string | null = null) =>
-  [CommunityQueryKeys.postList, boardCode, category] as const;
+  [CommunityQueryKeys.postList, boardCode, normalizeCommunityPostListCategory(category)] as const;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const communityPostListQueryKey = (boardCode: string, category: string | null = null) =>
[CommunityQueryKeys.postList, boardCode, category] as const;
export const COMMUNITY_INITIAL_CATEGORY = "전체";
export const COMMUNITY_POST_LIST_STALE_TIME = Infinity;
export const COMMUNITY_POST_LIST_GC_TIME = 1000 * 60 * 30; // 30분
export const normalizeCommunityPostListCategory = (category: string | null = null) =>
!category || category === COMMUNITY_INITIAL_CATEGORY ? null : category;
export const communityPostListQueryKey = (boardCode: string, category: string | null = null) =>
[CommunityQueryKeys.postList, boardCode, normalizeCommunityPostListCategory(category)] as const;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/apis/community/postListQuery.ts` around lines 8 - 9,
communityPostListQueryKey currently returns ["postList", boardCode, category]
and treats "전체" and null as different keys; update communityPostListQueryKey so
it normalizes the category parameter by mapping the string "전체" to null (e.g.,
const normalizedCategory = category === "전체" ? null : category) before returning
the tuple, ensuring all callers (including prefetches) use the same cache key
for the full list.


export const sortCommunityPosts = (posts: ListPost[]) =>
[...posts].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
5 changes: 5 additions & 0 deletions apps/web/src/apis/community/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// QueryKeys for community domain
export const CommunityQueryKeys = {
posts: "posts",
postList: "postList1", // 기존 api/boards와 동일한 키 유지
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useGetPostList } from "@/apis/community";
import { COMMUNITY_INITIAL_CATEGORY } from "@/apis/community/postListQuery";
import ButtonTab from "@/components/ui/ButtonTab";
import { COMMUNITY_BOARDS, COMMUNITY_CATEGORIES } from "@/constants/community";
import useReportedPostsStore from "@/lib/zustand/useReportedPostsStore";
import type { ListPost } from "@/types/community";
import { CommunityPostListSkeleton } from "./CommunityPageSkeleton";
import CommunityRegionSelector from "./CommunityRegionSelector";
import PostCards from "./PostCards";
import PostWriteButton from "./PostWriteButton";
Expand All @@ -23,11 +25,11 @@ interface CommunityPageContentProps {

const CommunityPageContent = ({ boardCode }: CommunityPageContentProps) => {
const router = useRouter();
const [category, setCategory] = useState<string | null>("전체");
const [category, setCategory] = useState<string | null>(COMMUNITY_INITIAL_CATEGORY);
const reportedPostIds = useReportedPostsStore((state) => state.reportedPostIds);
const blockedUserIds = useReportedPostsStore((state) => state.blockedUserIds);

const { data: posts = [] } = useGetPostList({
const { data: posts = [], isPending } = useGetPostList({
boardCode,
category,
});
Expand Down Expand Up @@ -81,7 +83,7 @@ const CommunityPageContent = ({ boardCode }: CommunityPageContentProps) => {
setChoice={setCategory}
style={{ padding: "10px 0 10px 18px" }}
/>
{<PostCards posts={visiblePosts} boardCode={boardCode} />}
{isPending ? <CommunityPostListSkeleton /> : <PostCards posts={visiblePosts} boardCode={boardCode} />}
<PostWriteButton onClick={postWriteHandler} />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const TAB_SKELETON_WIDTHS = ["w-12", "w-12", "w-12"];

export const CommunityPostListSkeleton = ({ itemCount = 5 }: { itemCount?: number }) => (
<div
className="flex flex-col overflow-hidden"
style={{
height: "calc(100vh - 220px)",
}}
aria-hidden="true"
>
{Array.from({ length: itemCount }).map((_, index) => (
<div key={index} className="flex justify-between border-b border-b-gray-c-100 px-5 py-4">
<div className="min-w-0 flex-1 animate-pulse">
<div className="flex items-center gap-2.5">
<div className="h-4 w-9 rounded bg-k-50" />
<div className="h-4 w-20 rounded bg-k-50" />
</div>
<div className="mt-3 h-5 w-3/4 rounded bg-k-50" />
<div className="mt-2 h-4 w-full rounded bg-k-50" />
<div className="mt-1.5 h-4 w-2/3 rounded bg-k-50" />
<div className="mt-3 flex gap-2.5">
<div className="h-4 w-8 rounded bg-k-50" />
<div className="h-4 w-8 rounded bg-k-50" />
</div>
</div>
<div className="ml-4 mt-3 h-20 w-20 shrink-0 animate-pulse rounded border border-k-100 bg-k-50" />
</div>
))}
</div>
);

const CommunityPageSkeleton = () => (
<div role="status" aria-label="커뮤니티 게시글을 불러오는 중입니다">
<div className="pb-3.5 pl-5 pt-5">
<div className="h-7 w-24 animate-pulse rounded bg-k-50" />
</div>
<div className="flex gap-2 overflow-hidden px-[18px] py-2.5">
{TAB_SKELETON_WIDTHS.map((width, index) => (
<div key={`${width}-${index}`} className={`${width} h-8 shrink-0 animate-pulse rounded-full bg-k-50`} />
))}
</div>
<CommunityPostListSkeleton />
</div>
);

export default CommunityPageSkeleton;
5 changes: 3 additions & 2 deletions apps/web/src/app/community/[boardCode]/PostCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const PostCards = ({ posts, boardCode }: PostCardsProps) => {
data-index={virtualItem.index}
>
<Link href={`/community/${boardCode}/${post.id}`} className="no-underline">
<PostCard post={post} />
<PostCard post={post} priorityImage={virtualItem.index === 0} />
</Link>
</div>
);
Expand All @@ -71,7 +71,7 @@ const PostCards = ({ posts, boardCode }: PostCardsProps) => {

export default PostCards;

export const PostCard = ({ post }: { post: ListPost }) => (
export const PostCard = ({ post, priorityImage = false }: { post: ListPost; priorityImage?: boolean }) => (
<div className="flex justify-between border-b border-b-gray-c-100 px-5 py-4">
<div className="flex flex-col">
<div className="flex items-center truncate font-serif text-gray-250">
Expand Down Expand Up @@ -104,6 +104,7 @@ export const PostCard = ({ post }: { post: ListPost }) => (
sizes="80px"
alt="게시글 사진"
fallbackSrc="/images/article-thumb.png"
loading={priorityImage ? "eager" : undefined}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/community/[boardCode]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import TopDetailNavigation from "@/components/layout/TopDetailNavigation";
import CommunityPageSkeleton from "./CommunityPageSkeleton";

const CommunityLoading = () => (
<div className="w-full">
<TopDetailNavigation title="커뮤니티" />
<CommunityPageSkeleton />
</div>
Comment on lines +5 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

1) 로딩 상태에서 상단 고정 네비게이션과 본문이 겹칠 수 있어요.

`TopDetailNavigation`가 `fixed` 높이(`h-14`)라서, 현재 구조에서는 스켈레톤 시작 영역이 네비 뒤로 들어갈 수 있습니다. 로딩 화면 래퍼에 상단 오프셋을 맞춰 주세요.
수정 예시
 const CommunityLoading = () => (
-  <div className="w-full">
+  <div className="w-full pt-14">
     <TopDetailNavigation title="커뮤니티" />
     <CommunityPageSkeleton />
   </div>
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="w-full">
<TopDetailNavigation title="커뮤니티" />
<CommunityPageSkeleton />
</div>
<div className="w-full pt-14">
<TopDetailNavigation title="커뮤니티" />
<CommunityPageSkeleton />
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/community/`[boardCode]/loading.tsx around lines 5 - 8,
TopDetailNavigation is fixed with height h-14 so CommunityPageSkeleton can
render underneath it during loading; update the loading wrapper around
TopDetailNavigation and CommunityPageSkeleton (the div currently using
className="w-full") to include a top offset equal to the nav height (e.g., add
pt-14 or equivalent padding/margin) so the skeleton content is pushed below the
fixed TopDetailNavigation; ensure the offset matches the nav's h-14 to avoid
overlap.

);

export default CommunityLoading;
38 changes: 37 additions & 1 deletion apps/web/src/app/community/[boardCode]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import type { Metadata } from "next";
import {
COMMUNITY_INITIAL_CATEGORY,
COMMUNITY_POST_LIST_GC_TIME,
COMMUNITY_POST_LIST_STALE_TIME,
communityPostListQueryKey,
sortCommunityPosts,
} from "@/apis/community/postListQuery";
import { getPostListServer } from "@/apis/community/server";
import TopDetailNavigation from "@/components/layout/TopDetailNavigation";
import { COMMUNITY_BOARDS } from "@/constants/community";
import { NO_INDEX_ROBOTS } from "@/utils/seo";
Expand Down Expand Up @@ -28,13 +37,40 @@ export async function generateMetadata({ params }: CommunityPageProps): Promise<
};
}

const createCommunityPostListQueryClient = async (boardCode: string) => {
const queryClient = new QueryClient();

await queryClient.prefetchQuery({
queryKey: communityPostListQueryKey(boardCode, COMMUNITY_INITIAL_CATEGORY),
queryFn: async () => {
const result = await getPostListServer({
boardCode,
category: COMMUNITY_INITIAL_CATEGORY,
});

if (!result.ok) {
throw new Error(`Failed to fetch community posts: ${result.status}`);
}

return sortCommunityPosts(result.data);
},
staleTime: COMMUNITY_POST_LIST_STALE_TIME,
gcTime: COMMUNITY_POST_LIST_GC_TIME,
});

return queryClient;
};

const CommunityPage = async ({ params }: CommunityPageProps) => {
const { boardCode } = await params;
const queryClient = await createCommunityPostListQueryClient(boardCode);

return (
<div className="w-full">
<TopDetailNavigation title="커뮤니티" />
<CommunityPageContent boardCode={boardCode} />
<HydrationBoundary state={dehydrate(queryClient)}>
<CommunityPageContent boardCode={boardCode} />
</HydrationBoundary>
</div>
);
};
Expand Down
Loading