feat: 메인화면 UI 수정, 공지 카테고리 설정, 아카이브 기능 추가
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m47s

This commit is contained in:
ParkWonYeop
2025-12-27 20:26:40 +09:00
parent 2361e9a3ff
commit b952d3a491
9 changed files with 404 additions and 154 deletions

View File

@@ -1,39 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getPosts } from '@/api/posts';
import PostCard from '@/components/post/PostCard';
import PostListItem from '@/components/post/PostListItem'; // 새로 만든 컴포넌트 import
import PostListItem from '@/components/post/PostListItem';
import { Post } from '@/types';
import { ChevronLeft, ChevronRight, Loader2, LayoutGrid, List } from 'lucide-react';
import { clsx } from 'clsx';
import { Loader2, Megaphone, Flame, Clock, ChevronRight } from 'lucide-react';
import Link from 'next/link';
export default function Home() {
const [page, setPage] = useState(0);
// 뷰 모드 상태: 'grid' 또는 'list'
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const PAGE_SIZE = 10;
// 💡 사용자 선호 모드를 로컬 스토리지에서 불러오기 (UX 향상)
useEffect(() => {
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
if (savedMode) setViewMode(savedMode);
}, []);
// 모드 변경 시 로컬 스토리지에 저장
const handleViewModeChange = (mode: 'grid' | 'list') => {
setViewMode(mode);
localStorage.setItem('postViewMode', mode);
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts({ page, size: PAGE_SIZE }),
placeholderData: (previousData) => previousData,
// 1. 공지사항 조회 (최대 3개, 최신순)
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
queryKey: ['posts', 'notices'],
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
});
if (isLoading) {
// 2. 최신 게시글 조회 (넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'latest'],
queryFn: () => getPosts({ size: 10, sort: 'createdAt,desc' }),
});
// 3. 인기 게시글 조회 (조회수순, 넉넉히 10개 가져와서 공지 제외하고 3개만 사용)
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'popular'],
queryFn: () => getPosts({ size: 10, sort: 'viewCount,desc' }),
});
// 로딩 상태 처리
if (isNoticesLoading || isLatestLoading || isPopularLoading) {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
@@ -41,103 +36,97 @@ export default function Home() {
);
}
if (isError) {
return (
<div className="max-w-4xl mx-auto p-6 text-center pt-20 text-red-500">
. .
</div>
);
}
const handlePrevPage = () => {
setPage((old) => Math.max(old - 1, 0));
// 데이터 필터링 헬퍼 함수
const filterPosts = (posts: Post[] | undefined, limit: number) => {
if (!posts) return [];
return posts
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
.slice(0, limit);
};
const handleNextPage = () => {
if (!data?.last) {
setPage((old) => old + 1);
}
};
const notices = noticesData?.content || [];
// 5개 -> 3개로 수정
const latestPosts = filterPosts(latestData?.content, 3);
const popularPosts = filterPosts(popularData?.content, 3);
return (
<main className="max-w-4xl mx-auto p-6 min-h-screen">
{/* 헤더 영역 (제목 + 뷰 모드 버튼) */}
<header className="mb-8 mt-10 flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<main className="max-w-4xl mx-auto px-4 py-8 space-y-16">
{/* 📢 1. 공지사항 섹션 (리스트 형태) */}
{notices.length > 0 && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
<Megaphone className="text-red-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"></h2>
</div>
<div className="flex flex-col gap-2">
{notices.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
</section>
)}
{/* 🕒 2. 최신 게시글 섹션 (카드 형태) */}
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-100">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Clock className="text-blue-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
<Link href="/archive" className="text-sm text-gray-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<ChevronRight size={14} />
</Link>
</div>
{/* 뷰 모드 토글 버튼 */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => handleViewModeChange('grid')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="카드형 보기"
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => handleViewModeChange('list')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
viewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
)}
title="리스트형 보기"
>
<List size={18} />
</button>
{latestPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 첫 번째 글은 강조를 위해 크게 보여줄 수도 있지만, 여기선 균일하게 배치 */}
{latestPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{/* 🔥 3. 인기 게시글 섹션 (카드 형태) */}
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-200">
<div className="flex items-center gap-2 mb-6">
<Flame className="text-orange-500" size={20} />
<h2 className="text-xl font-bold text-gray-800"> </h2>
</div>
</header>
{/* 게시글 목록 (조건부 렌더링) */}
{viewMode === 'grid' ? (
// 그리드 뷰
<section className="grid gap-6 md:grid-cols-2">
{data?.content.map((post: Post) => (
<PostCard key={post.id} post={post} />
))}
</section>
) : (
// 리스트 뷰
<section className="flex flex-col border-t border-gray-100">
{data?.content.map((post: Post) => (
<PostListItem key={post.id} post={post} />
))}
</section>
)}
{popularPosts.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{popularPosts.map((post) => (
<div key={post.id} className="h-full">
<PostCard post={post} />
</div>
))}
</div>
) : (
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
.
</div>
)}
</section>
{data?.content.length === 0 && (
<div className="text-center py-20 text-gray-500 bg-gray-50 rounded-lg">
.
</div>
)}
{/* 하단 여백 및 아카이브 링크 배너 */}
<div className="pt-8 pb-4 text-center">
<Link
href="/archive"
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-100 text-gray-600 rounded-full hover:bg-gray-200 transition-colors font-medium text-sm"
>
<ChevronRight size={16} />
</Link>
</div>
{data && data.content.length > 0 && (
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
<button
onClick={handlePrevPage}
disabled={page === 0}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
aria-label="이전 페이지"
>
<ChevronLeft size={24} />
</button>
<span className="text-sm font-medium text-gray-600">
Page <span className="text-gray-900 font-bold">{page + 1}</span> {data.totalPages > 0 && `/ ${data.totalPages}`}
</span>
<button
onClick={handleNextPage}
disabled={data.last || isPlaceholderData}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
aria-label="다음 페이지"
>
<ChevronRight size={24} />
</button>
</div>
)}
</main>
);
}