Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ export default function CommunityPostList<T extends BoardSummary>({

{/* 게시글 목록 */}
<InfiniteScroll.Contents<T>
className={cn('flex flex-col gap-4', className)}
className={cn('flex flex-col', className)}
virtualScroll={{
enabled: true,
estimateSize: 156, // 카드 평균 높이
estimateSize: 139, // 카드 평균 높이
overscan: 3,
}}
scrollStore={{
Expand All @@ -118,7 +118,7 @@ export default function CommunityPostList<T extends BoardSummary>({
// 안정적인 key 생성을 위한 함수 필수 전달
getItemKey={getItemKey}
renderItem={renderItem}
gap={16} // 4 * 4px = 16px
gap={16}
threshold={0.8}
>
{/* 무한스크롤 트리거 */}
Expand Down
53 changes: 34 additions & 19 deletions apps/web/src/app/main/community/components/FreeboardCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// src/components/CommunityCard.tsx
import Image from 'next/image';
import Card from '@/components/Card';
import { CategoryChip } from '@/components/chips/CategoryChip';
import { Category } from '../constants/categories';
import { relativeTime } from '@/utils/relativeTime';
import { Heart, MessageSquareMore } from 'lucide-react';
import { useRouter } from 'next/navigation';

import { formatCount } from '@/utils/formatCount';
import type { FreeboardSummary } from '@/generated/api/models';

export interface FreeBoardCardProps {
Expand All @@ -20,12 +21,12 @@ export function FreeBoardCard({
const {
postId,
title,
contentPreview, // API에서는 contentPreview 사용
contentPreview,
category,
likeCount,
commentCount,
createdAt,
author, // API에서는 author 사용
author,
} = post;

const router = useRouter();
Expand All @@ -36,23 +37,35 @@ export function FreeBoardCard({

return (
<Card
className="w-full flex flex-col gap-3"
className="w-full flex flex-col gap-2"
onClick={handleOnClick}
>
<div className="flex flex-col gap-1">
{isChip && category && (
<div className="flex items-center gap-1">
<CategoryChip category={category as Category} />
</div>
{isChip && category && (
<div className="flex items-center gap-1">
<CategoryChip category={category as Category} />
</div>
)}
<div className="flex flex-row justify-between items-center gap-4">
<section className="flex flex-col gap-1">
<h3 className="text-title2 truncate" title={title}>
{title}
</h3>
<p className="text-body truncate" title={contentPreview}>
{contentPreview}
</p>
</section>
{post.thumbnailUrl && (
<Image
src={post.thumbnailUrl}
alt="게시글 썸네일 이미지"
width={55}
height={55}
className="w-[55px] h-[55px] object-cover rounded-md flex-shrink-0"
/>
)}
<h3 className="text-title2 truncate" title={title}>
{title}
</h3>
<p className="text-body truncate" title={contentPreview}>
{contentPreview}
</p>
</div>
<div className="flex justify-between items-center">

<section className="flex justify-between items-center">
{/* 작성자 · 시간 */}
<span className="text-neutral-500 text-xs">
{author?.nickname ?? '알 수 없음'} ·{' '}
Expand All @@ -65,18 +78,20 @@ export function FreeBoardCard({
aria-label={`좋아요 ${likeCount ?? 0}개`}
>
<Heart className="w-4 h-4 text-neutral-500" />
<span className="text-xs">{likeCount ?? 0}</span>
<span className="text-xs">{formatCount(likeCount)}</span>
</div>
{/* 댓글 */}
<div
className="flex items-center gap-1"
aria-label={`댓글 ${commentCount ?? 0}개`}
>
<MessageSquareMore className="w-4 h-4 text-neutral-500" />
<span className="text-xs">{commentCount ?? 0}</span>
<span className="text-xs">
{formatCount(commentCount)}
</span>
</div>
</div>
</div>
</section>
</Card>
);
}
7 changes: 5 additions & 2 deletions apps/web/src/app/main/community/components/SortHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function SortHeader({
)}
aria-label="정렬 옵션"
>
<p className="text-body2 dark:text-white">
<p className="text-body2 text-neutral-800 dark:text-white">
총 {totalCount}개 게시글
</p>

Expand All @@ -39,7 +39,10 @@ export function SortHeader({
onValueChange={(value) => onFilterChange(value as SortValue)}
size="sm"
>
<Select.Trigger placeholder="정렬 선택" />
<Select.Trigger
className="border-none text-neutral-800 dark:text-white justify-end text-sm"
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

Select.Trigger에 border-none을 적용하여 기본 테두리를 제거하고 있습니다. 하지만 SelectTrigger 컴포넌트 내부에서 이미 border 스타일이 정의되어 있으므로(SelectTrigger.tsx 164번 라인), border-none을 사용하면 Tailwind의 우선순위에 따라 의도대로 작동하지 않을 수 있습니다. 대신 !border-0 또는 border-transparent를 사용하거나, SelectTrigger 컴포넌트에 noBorder prop을 추가하는 것을 고려하세요.

Suggested change
className="border-none text-neutral-800 dark:text-white justify-end text-sm"
className="!border-0 text-neutral-800 dark:text-white justify-end text-sm"

Copilot uses AI. Check for mistakes.
placeholder="정렬 선택"
/>
<Select.Portal>
<Select.Content>
{sortOptions.map((option) => (
Expand Down
102 changes: 102 additions & 0 deletions apps/web/src/app/main/community/freeboard/ClientPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use client';

import React, { useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { PillChipsTab } from '@/components/tabs/PillChipsTab';
import { CATEGORIES, Category } from '../constants/categories';
import { SortHeader } from '../components/SortHeader';
import { SortValue } from '@/types/options.types';
import { SORT_OPTIONS } from '../constants/sortOptions';
import FloatingButton from '@/components/buttons/FloatingButton';
import { FreeBoardCard } from '../components/FreeboardCard';
import CommunityPostList from '../components/CommunityPostList';
import { FreeboardSummary } from '@/generated/api/models';
import {
getFreeboardPostsByCursor,
getGetFreeboardPostsByCursorQueryKey,
} from '@/generated/api/endpoints/freeboard/freeboard';

/**
* 자유 게시판 클라이언트 메인 페이지
*
* @description
* - 카테고리별 게시글 목록을 보여주는 페이지
* - 무한스크롤 기능 포함
* - 카테고리 및 정렬 옵션 선택 가능
*/

export default function FreeboardClientPage() {
const [category, setCategory] = useState<Category | null>(null);
const [sortOption, setSortOption] = useState<SortValue>('LATEST');

// 무한스크롤 데이터 페칭
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
error,
refetch,
} = useInfiniteQuery({
queryKey: getGetFreeboardPostsByCursorQueryKey({
// queryKey 생성 함수 사용
category: category ?? undefined, // null일 경우 undefined로 변환
sort: sortOption,
}),
queryFn: ({ pageParam, signal }) =>
getFreeboardPostsByCursor(
{
category: category ?? undefined,
sort: sortOption,
cursor: pageParam,
size: 10,
},
signal,
),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.hasNext ? lastPage.nextCursor : undefined;
},
});

// 모든 페이지의 게시글을 하나의 배열로 합치기
const allFreeboardPosts: FreeboardSummary[] =
data?.pages.flatMap((page) => page.posts ?? []) ?? [];
// 총 게시글 개수
const totalCount = data?.pages[0]?.totalCount ?? 0;

return (
<main className="w-full h-full flex flex-col">
<PillChipsTab<Category>
chips={CATEGORIES}
activeValue={category}
onChange={setCategory}
showAll
ariaLabel="카테고리 선택 필터"
/>
<SortHeader
totalCount={totalCount}
sortOptions={SORT_OPTIONS}
currentValue={sortOption}
onFilterChange={setSortOption}
className="px-5"
/>
<CommunityPostList<FreeboardSummary>
items={allFreeboardPosts}
hasNextPage={hasNextPage || false}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
initialLoading={isLoading}
error={error}
onRetry={() => refetch()}
getItemKey={(post, index) => post.postId ?? `post-${index}`}
renderItem={(post) => (
<FreeBoardCard post={post} isChip={true} />
)}
storageKey="freeboard-post-list-scroll"
/>
<FloatingButton categories={CATEGORIES} />
</main>
);
}
97 changes: 21 additions & 76 deletions apps/web/src/app/main/community/freeboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,101 +1,46 @@
'use client';
import React, { useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { PillChipsTab } from '@/components/tabs/PillChipsTab';
import { CATEGORIES, Category } from '../constants/categories';
import { SortHeader } from '../components/SortHeader';
import { SortValue } from '@/types/options.types';
import { SORT_OPTIONS } from '../constants/sortOptions';
import FloatingButton from '@/components/buttons/FloatingButton';
import { FreeBoardCard } from '../components/FreeboardCard';
import CommunityPostList from '../components/CommunityPostList';

import { FreeboardSummary } from '@/generated/api/models';
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from '@tanstack/react-query';
import {
getFreeboardPostsByCursor,
getGetFreeboardPostsByCursorQueryKey,
} from '@/generated/api/endpoints/freeboard/freeboard';
import ClientPage from './ClientPage';

/**
* 자유 게시판 메인 페이지
* 자유 게시판 메인 페이지 (서버 컴포넌트)
*
* @description
* - 카테고리별 게시글 목록을 보여주는 페이지
* - 무한스크롤 기능 포함
* - 카테고리 및 정렬 옵션 선택 가능
* - 전체 카테고리
* - 최신순 정렬
* - 10개 게시글 프리패치
*/

export default function FreeboardPage() {
const [category, setCategory] = useState<Category | null>(null);
const [sortOption, setSortOption] = useState<SortValue>('LATEST');
export default async function FreeboardPage() {
const queryClient = new QueryClient();

// 무한스크롤 데이터 페칭
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
error,
refetch,
} = useInfiniteQuery({
await queryClient.prefetchInfiniteQuery({
queryKey: getGetFreeboardPostsByCursorQueryKey({
// queryKey 생성 함수 사용
category: category ?? undefined, // null일 경우 undefined로 변환
sort: sortOption,
category: undefined,
sort: 'LATEST',
}),
queryFn: ({ pageParam, signal }) =>
queryFn: async ({ signal }) =>
getFreeboardPostsByCursor(
{
category: category ?? undefined,
sort: sortOption,
cursor: pageParam,
size: 10,
},
{ category: undefined, sort: 'LATEST', size: 10 },
signal,
),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.hasNext ? lastPage.nextCursor : undefined;
},
pages: 1,
});

// 모든 페이지의 게시글을 하나의 배열로 합치기
const allFreeboardPosts: FreeboardSummary[] =
data?.pages.flatMap((page) => page.posts ?? []) ?? [];
// 총 게시글 개수
const totalCount = data?.pages[0]?.totalCount ?? 0;

return (
<main className="w-full h-full flex flex-col">
<PillChipsTab<Category>
chips={CATEGORIES}
activeValue={category}
onChange={setCategory}
showAll
ariaLabel="카테고리 선택 필터"
/>
<SortHeader
totalCount={totalCount}
sortOptions={SORT_OPTIONS}
currentValue={sortOption}
onFilterChange={setSortOption}
/>
<CommunityPostList<FreeboardSummary>
items={allFreeboardPosts}
hasNextPage={hasNextPage || false}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
initialLoading={isLoading}
error={error}
onRetry={() => refetch()}
getItemKey={(post, index) => post.postId ?? `post-${index}`}
renderItem={(post) => (
<FreeBoardCard post={post} isChip={true} />
)}
storageKey="freeboard-post-list-scroll"
/>
<FloatingButton categories={CATEGORIES} />
</main>
<HydrationBoundary state={dehydrate(queryClient)}>
<ClientPage />
</HydrationBoundary>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/tabs/PillChipsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import React, { useState, useRef, useMemo } from 'react';
import { Pressable } from '../Pressable';
import { twMerge } from 'tailwind-merge';
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,6 @@ export const config = {
* - _next/image (이미지 최적화)
* - favicon.ico (파비콘)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
'/((?!api|_next/static|_next/image|favicon.ico|\\.well-known).*)',
],
};
Loading