From b952d3a491d17a0f5a6225caf5712e637ebbe94f Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Sat, 27 Dec 2025 20:26:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?UI=20=EC=88=98=EC=A0=95,=20=EA=B3=B5=EC=A7=80=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=A4=EC=A0=95,=20=EC=95=84?= =?UTF-8?q?=EC=B9=B4=EC=9D=B4=EB=B8=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/posts.ts | 13 +- src/app/archive/page.tsx | 150 ++++++++++++++++++ src/app/category/[id]/page.tsx | 22 ++- src/app/login/page.tsx | 5 +- src/app/page.tsx | 217 +++++++++++++-------------- src/app/signup/page.tsx | 6 +- src/components/layout/Sidebar.tsx | 82 ++++++++-- src/components/post/PostCard.tsx | 34 ++++- src/components/post/PostListItem.tsx | 29 +++- 9 files changed, 404 insertions(+), 154 deletions(-) create mode 100644 src/app/archive/page.tsx diff --git a/src/api/posts.ts b/src/api/posts.ts index f4a88b9..5fc2bd0 100644 --- a/src/api/posts.ts +++ b/src/api/posts.ts @@ -1,18 +1,20 @@ import { http } from './http'; import { ApiResponse, PostListResponse, Post } from '@/types'; -// 1. 게시글 목록 조회 (검색, 카테고리, 태그 필터링 지원) +// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원) export const getPosts = async (params?: { page?: number; size?: number; keyword?: string; category?: string; tag?: string; + sort?: string; // 🆕 정렬 옵션 추가 (예: 'viewCount,desc') }) => { const response = await http.get>('/api/posts', { params: { ...params, - sort: 'createdAt,desc', + // 정렬 값이 없으면 기본값(최신순) 적용 + sort: params?.sort || 'createdAt,desc', } }); return response.data.data; @@ -33,20 +35,19 @@ export const getPost = async (slug: string) => { return response.data.data; }; -// 4. 게시글 작성 (추가됨) -// PostSaveRequest 타입에 맞춰 데이터를 보냅니다. +// 4. 게시글 작성 export const createPost = async (data: any) => { const response = await http.post>('/api/admin/posts', data); return response.data; }; -// 5. 게시글 수정 (추가됨) +// 5. 게시글 수정 export const updatePost = async (id: number, data: any) => { const response = await http.put>(`/api/admin/posts/${id}`, data); return response.data; }; -// 6. 게시글 삭제 (추가됨) +// 6. 게시글 삭제 export const deletePost = async (id: number) => { const response = await http.delete>(`/api/admin/posts/${id}`); return response.data; diff --git a/src/app/archive/page.tsx b/src/app/archive/page.tsx new file mode 100644 index 0000000..d1087c8 --- /dev/null +++ b/src/app/archive/page.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { getPosts } from '@/api/posts'; +import { Post } from '@/types'; +import { Loader2, Calendar, Archive, FileText, ChevronRight } from 'lucide-react'; +import { useMemo } from 'react'; +import Link from 'next/link'; +import { format } from 'date-fns'; +import { clsx } from 'clsx'; + +export default function ArchivePage() { + // 1. 전체 게시글 조회 (최대 1000개) + const { data, isLoading } = useQuery({ + queryKey: ['posts', 'all'], + queryFn: () => getPosts({ page: 0, size: 1000 }), + staleTime: 1000 * 60 * 5, // 5분 캐시 + }); + + // 2. 게시글 그룹화 (연도 -> 월) + const archiveGroups = useMemo(() => { + if (!data?.content) return {}; + + const groups: { [year: string]: { [month: string]: Post[] } } = {}; + + data.content.forEach((post) => { + const date = new Date(post.createdAt); + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); // '01', '02' 형식 + + if (!groups[year]) groups[year] = {}; + if (!groups[year][month]) groups[year][month] = []; + + groups[year][month].push(post); + }); + + return groups; + }, [data]); + + // 3. 연도 내림차순 정렬 + const sortedYears = useMemo(() => { + return Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a)); + }, [archiveGroups]); + + // 총 게시글 수 + const totalPosts = data?.totalElements || 0; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 헤더 섹션 */} +
+

+ + Archives +

+

+ 지금까지 작성한 {totalPosts}개의 글이 기록되어 있습니다. +

+
+ + {/* 타임라인 컨텐츠 */} + {sortedYears.length > 0 ? ( +
+ {/* 타임라인 수직선 (좌측 장식) */} +
+ + {sortedYears.map((year) => { + const months = archiveGroups[year]; + // 월 내림차순 정렬 + const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a)); + + return ( +
+ {/* 연도 헤더 */} +
+
+
+
+

{year}

+
+ + {/* 월별 그룹 */} +
+ {sortedMonths.map((month) => { + const posts = months[month]; + // 게시글 날짜 내림차순 정렬 + const sortedPosts = posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return ( +
+

+ + {month}월 + + {posts.length} + +

+ +
+ {sortedPosts.map((post) => ( + +
+
+

+ {post.title} +

+
+ + {post.categoryName} + + + + {format(new Date(post.createdAt), 'yyyy.MM.dd')} + +
+
+ +
+ + ))} +
+
+ ); + })} +
+
+ ); + })} +
+ ) : ( +
+ +

아직 작성된 기록이 없습니다.

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/category/[id]/page.tsx b/src/app/category/[id]/page.tsx index d6bce7f..11cb109 100644 --- a/src/app/category/[id]/page.tsx +++ b/src/app/category/[id]/page.tsx @@ -4,7 +4,7 @@ import { use, useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getPostsByCategory } from '@/api/posts'; import PostCard from '@/components/post/PostCard'; -import PostListItem from '@/components/post/PostListItem'; // 추가 +import PostListItem from '@/components/post/PostListItem'; import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react'; import { notFound } from 'next/navigation'; import { clsx } from 'clsx'; @@ -13,20 +13,34 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string const { id } = use(params); const categoryName = decodeURIComponent(id); const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName; + + // 📢 공지 카테고리인지 판별 + const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice'; const [page, setPage] = useState(0); + + // 공지사항이면 기본값을 'list'로 설정 const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const PAGE_SIZE = 10; - // 💡 로컬 스토리지에서 뷰 모드 불러오기 useEffect(() => { + // 📢 공지 카테고리는 강제로 리스트 뷰 적용 + if (isNoticeCategory) { + setViewMode('list'); + return; + } + + // 그 외에는 로컬 스토리지 설정 따름 const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list'; if (savedMode) setViewMode(savedMode); - }, []); + }, [isNoticeCategory]); const handleViewModeChange = (mode: 'grid' | 'list') => { setViewMode(mode); - localStorage.setItem('postViewMode', mode); + // 공지 카테고리가 아닐 때만 사용자 설정을 저장 (공지는 뷰 강제이므로 저장 안 함) + if (!isNoticeCategory) { + localStorage.setItem('postViewMode', mode); + } }; const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({ diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1fbe6f9..5b11d03 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -39,8 +39,9 @@ export default function LoginPage() { }; return ( -
-
+ // 🎨 배경색 수정: bg-gray-50 -> bg-white +
+

로그인

블로그에 오신 것을 환영합니다.

diff --git a/src/app/page.tsx b/src/app/page.tsx index be0f1e5..d63f53b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 (
@@ -41,103 +36,97 @@ export default function Home() { ); } - if (isError) { - return ( -
- 게시글을 불러오지 못했습니다. 서버가 켜져 있는지 확인해주세요. -
- ); - } - - 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 ( -
- {/* 헤더 영역 (제목 + 뷰 모드 버튼) */} -
-

전체 게시글

+
+ + {/* 📢 1. 공지사항 섹션 (리스트 형태) */} + {notices.length > 0 && ( +
+
+ +

공지사항

+
+
+ {notices.map((post) => ( + + ))} +
+
+ )} + + {/* 🕒 2. 최신 게시글 섹션 (카드 형태) */} +
+
+
+ +

최신 포스트

+
+ + 전체보기 + +
- {/* 뷰 모드 토글 버튼 */} -
- - + {latestPosts.length > 0 ? ( +
+ {/* 첫 번째 글은 강조를 위해 크게 보여줄 수도 있지만, 여기선 균일하게 배치 */} + {latestPosts.map((post) => ( +
+ +
+ ))} +
+ ) : ( +
+ 아직 작성된 글이 없습니다. +
+ )} +
+ + {/* 🔥 3. 인기 게시글 섹션 (카드 형태) */} +
+
+ +

인기 포스트

-
- {/* 게시글 목록 (조건부 렌더링) */} - {viewMode === 'grid' ? ( - // 그리드 뷰 -
- {data?.content.map((post: Post) => ( - - ))} -
- ) : ( - // 리스트 뷰 -
- {data?.content.map((post: Post) => ( - - ))} -
- )} + {popularPosts.length > 0 ? ( +
+ {popularPosts.map((post) => ( +
+ +
+ ))} +
+ ) : ( +
+ 아직 인기 글이 집계되지 않았습니다. +
+ )} + - {data?.content.length === 0 && ( -
- 작성된 게시글이 없습니다. -
- )} + {/* 하단 여백 및 아카이브 링크 배너 */} +
+ + 모든 글 보러가기 + +
- {data && data.content.length > 0 && ( -
- - - - Page {page + 1} {data.totalPages > 0 && `/ ${data.totalPages}`} - - - -
- )}
); } \ No newline at end of file diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index fb50927..27c33b7 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,4 +1,3 @@ -// src/app/signup/page.tsx 'use client'; import { useState } from 'react'; @@ -59,7 +58,8 @@ export default function SignupPage() { }; return ( -
+ // 🎨 배경색 수정: bg-gray-50 -> bg-white +
{/* 헤더 */} @@ -137,7 +137,7 @@ export default function SignupPage() { disabled={loading} className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400" > - {loading ? '확인 중...' : '인증 완료'} + {loading ? '처리 중...' : '인증 완료'} )}
- {sortedChildren.length > 0 && ( -
+ {isExpanded && sortedChildren.length > 0 && ( +
{sortedChildren.map((child) => ( { @@ -187,7 +224,6 @@ export default function Sidebar() { queryFn: getCategories, }); - // 🆕 최상위 카테고리 ID 순 정렬 const sortedCategories = useMemo(() => { if (!categories) return undefined; return [...categories].sort((a, b) => a.id - b.id); @@ -234,8 +270,6 @@ export default function Sidebar() { }); const handleAddCategory = (parentId: number | null) => { - // 🎨 Prompt 대신 간단한 로직 유지 (복잡도 증가 방지) - // 실제로는 모달로 바꾸는게 좋지만, 여기선 일단 Toast만 적용 const name = prompt('새 카테고리 이름을 입력하세요:'); if (!name || !name.trim()) return; createCategoryMutation.mutate({ name, parentId }); @@ -332,7 +366,6 @@ export default function Sidebar() { )}
- {/* 🛠️ 수정됨: 로딩 중일 때는 이미지 대신 스켈레톤 표시 */} {isProfileLoading ? (
) : ( @@ -360,8 +393,30 @@ export default function Sidebar() {