feat: 메인화면 UI 수정, 공지 카테고리 설정, 아카이브 기능 추가
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m47s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m47s
This commit is contained in:
@@ -1,18 +1,20 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
import { ApiResponse, PostListResponse, Post } from '@/types';
|
import { ApiResponse, PostListResponse, Post } from '@/types';
|
||||||
|
|
||||||
// 1. 게시글 목록 조회 (검색, 카테고리, 태그 필터링 지원)
|
// 1. 게시글 목록 조회 (검색, 카테고리, 태그, 정렬 필터링 지원)
|
||||||
export const getPosts = async (params?: {
|
export const getPosts = async (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
sort?: string; // 🆕 정렬 옵션 추가 (예: 'viewCount,desc')
|
||||||
}) => {
|
}) => {
|
||||||
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
|
const response = await http.get<ApiResponse<PostListResponse>>('/api/posts', {
|
||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
sort: 'createdAt,desc',
|
// 정렬 값이 없으면 기본값(최신순) 적용
|
||||||
|
sort: params?.sort || 'createdAt,desc',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@@ -33,20 +35,19 @@ export const getPost = async (slug: string) => {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. 게시글 작성 (추가됨)
|
// 4. 게시글 작성
|
||||||
// PostSaveRequest 타입에 맞춰 데이터를 보냅니다.
|
|
||||||
export const createPost = async (data: any) => {
|
export const createPost = async (data: any) => {
|
||||||
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
const response = await http.post<ApiResponse<Post>>('/api/admin/posts', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. 게시글 수정 (추가됨)
|
// 5. 게시글 수정
|
||||||
export const updatePost = async (id: number, data: any) => {
|
export const updatePost = async (id: number, data: any) => {
|
||||||
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
const response = await http.put<ApiResponse<Post>>(`/api/admin/posts/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. 게시글 삭제 (추가됨)
|
// 6. 게시글 삭제
|
||||||
export const deletePost = async (id: number) => {
|
export const deletePost = async (id: number) => {
|
||||||
const response = await http.delete<ApiResponse<null>>(`/api/admin/posts/${id}`);
|
const response = await http.delete<ApiResponse<null>>(`/api/admin/posts/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
150
src/app/archive/page.tsx
Normal file
150
src/app/archive/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex justify-center items-center min-h-[50vh]">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-10 px-4">
|
||||||
|
{/* 헤더 섹션 */}
|
||||||
|
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
|
||||||
|
<Archive className="text-blue-600" size={32} />
|
||||||
|
<span>Archives</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
지금까지 작성한 <span className="text-blue-600 font-bold">{totalPosts}</span>개의 글이 기록되어 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 컨텐츠 */}
|
||||||
|
{sortedYears.length > 0 ? (
|
||||||
|
<div className="space-y-12 relative">
|
||||||
|
{/* 타임라인 수직선 (좌측 장식) */}
|
||||||
|
<div className="absolute left-4 top-4 bottom-4 w-0.5 bg-gray-100 hidden md:block" />
|
||||||
|
|
||||||
|
{sortedYears.map((year) => {
|
||||||
|
const months = archiveGroups[year];
|
||||||
|
// 월 내림차순 정렬
|
||||||
|
const sortedMonths = Object.keys(months).sort((a, b) => Number(b) - Number(a));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={year} className="relative">
|
||||||
|
{/* 연도 헤더 */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="hidden md:flex items-center justify-center w-9 h-9 rounded-full bg-blue-50 border-4 border-white shadow-sm z-10">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800">{year}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 월별 그룹 */}
|
||||||
|
<div className="space-y-8 md:pl-12">
|
||||||
|
{sortedMonths.map((month) => {
|
||||||
|
const posts = months[month];
|
||||||
|
// 게시글 날짜 내림차순 정렬
|
||||||
|
const sortedPosts = posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={month} className="group">
|
||||||
|
<h3 className="text-lg font-bold text-gray-600 mb-4 flex items-center gap-2">
|
||||||
|
<Calendar size={18} className="text-gray-400" />
|
||||||
|
{month}월
|
||||||
|
<span className="text-xs font-normal bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
|
||||||
|
{posts.length}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{sortedPosts.map((post) => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
href={`/posts/${post.slug}`}
|
||||||
|
className="block bg-white border border-gray-100 rounded-lg p-4 hover:border-blue-200 hover:shadow-md transition-all duration-200 group/item"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-base font-medium text-gray-800 truncate group-hover/item:text-blue-600 transition-colors">
|
||||||
|
{post.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-400">
|
||||||
|
<span className="bg-gray-50 px-1.5 py-0.5 rounded text-gray-500">
|
||||||
|
{post.categoryName}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="text-gray-300 group-hover/item:text-blue-400 transition-colors" size={20} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-20 bg-gray-50 rounded-xl border border-dashed border-gray-200">
|
||||||
|
<FileText className="mx-auto text-gray-300 mb-3" size={48} />
|
||||||
|
<p className="text-gray-500">아직 작성된 기록이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { use, useState, useEffect } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPostsByCategory } from '@/api/posts';
|
import { getPostsByCategory } from '@/api/posts';
|
||||||
import PostCard from '@/components/post/PostCard';
|
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 { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
@@ -14,19 +14,33 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
|
|||||||
const categoryName = decodeURIComponent(id);
|
const categoryName = decodeURIComponent(id);
|
||||||
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
const apiCategoryName = categoryName === 'uncategorized' ? '미분류' : categoryName;
|
||||||
|
|
||||||
|
// 📢 공지 카테고리인지 판별
|
||||||
|
const isNoticeCategory = apiCategoryName === '공지' || apiCategoryName.toLowerCase() === 'notice';
|
||||||
|
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// 공지사항이면 기본값을 'list'로 설정
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
// 💡 로컬 스토리지에서 뷰 모드 불러오기
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 📢 공지 카테고리는 강제로 리스트 뷰 적용
|
||||||
|
if (isNoticeCategory) {
|
||||||
|
setViewMode('list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외에는 로컬 스토리지 설정 따름
|
||||||
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
const savedMode = localStorage.getItem('postViewMode') as 'grid' | 'list';
|
||||||
if (savedMode) setViewMode(savedMode);
|
if (savedMode) setViewMode(savedMode);
|
||||||
}, []);
|
}, [isNoticeCategory]);
|
||||||
|
|
||||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
localStorage.setItem('postViewMode', mode);
|
// 공지 카테고리가 아닐 때만 사용자 설정을 저장 (공지는 뷰 강제이므로 저장 안 함)
|
||||||
|
if (!isNoticeCategory) {
|
||||||
|
localStorage.setItem('postViewMode', mode);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
const { data: postsData, isLoading, error, isPlaceholderData } = useQuery({
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-gray-50">
|
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
||||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8">
|
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-white">
|
||||||
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 border border-gray-100">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
||||||
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
||||||
|
|||||||
211
src/app/page.tsx
211
src/app/page.tsx
@@ -1,39 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPosts } from '@/api/posts';
|
import { getPosts } from '@/api/posts';
|
||||||
import PostCard from '@/components/post/PostCard';
|
import PostCard from '@/components/post/PostCard';
|
||||||
import PostListItem from '@/components/post/PostListItem'; // 새로 만든 컴포넌트 import
|
import PostListItem from '@/components/post/PostListItem';
|
||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
import { ChevronLeft, ChevronRight, Loader2, LayoutGrid, List } from 'lucide-react';
|
import { Loader2, Megaphone, Flame, Clock, ChevronRight } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [page, setPage] = useState(0);
|
// 1. 공지사항 조회 (최대 3개, 최신순)
|
||||||
// 뷰 모드 상태: 'grid' 또는 'list'
|
const { data: noticesData, isLoading: isNoticesLoading } = useQuery({
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
queryKey: ['posts', 'notices'],
|
||||||
const PAGE_SIZE = 10;
|
queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }),
|
||||||
|
|
||||||
// 💡 사용자 선호 모드를 로컬 스토리지에서 불러오기 (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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex justify-center items-center h-screen">
|
<div className="flex justify-center items-center h-screen">
|
||||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||||
@@ -41,103 +36,97 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
// 데이터 필터링 헬퍼 함수
|
||||||
return (
|
const filterPosts = (posts: Post[] | undefined, limit: number) => {
|
||||||
<div className="max-w-4xl mx-auto p-6 text-center pt-20 text-red-500">
|
if (!posts) return [];
|
||||||
게시글을 불러오지 못했습니다. 서버가 켜져 있는지 확인해주세요.
|
return posts
|
||||||
</div>
|
.filter((post) => post.categoryName !== '공지' && post.categoryName.toLowerCase() !== 'notice')
|
||||||
);
|
.slice(0, limit);
|
||||||
}
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
setPage((old) => Math.max(old - 1, 0));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const notices = noticesData?.content || [];
|
||||||
if (!data?.last) {
|
// 5개 -> 3개로 수정
|
||||||
setPage((old) => old + 1);
|
const latestPosts = filterPosts(latestData?.content, 3);
|
||||||
}
|
const popularPosts = filterPosts(popularData?.content, 3);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto p-6 min-h-screen">
|
<main className="max-w-4xl mx-auto px-4 py-8 space-y-16">
|
||||||
{/* 헤더 영역 (제목 + 뷰 모드 버튼) */}
|
|
||||||
<header className="mb-8 mt-10 flex items-center justify-between">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">전체 게시글</h1>
|
|
||||||
|
|
||||||
{/* 뷰 모드 토글 버튼 */}
|
{/* 📢 1. 공지사항 섹션 (리스트 형태) */}
|
||||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
|
{notices.length > 0 && (
|
||||||
<button
|
<section className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
onClick={() => handleViewModeChange('grid')}
|
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
|
||||||
className={clsx(
|
<Megaphone className="text-red-500" size={20} />
|
||||||
"p-2 rounded-md transition-all duration-200",
|
<h2 className="text-xl font-bold text-gray-800">공지사항</h2>
|
||||||
viewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
|
</div>
|
||||||
)}
|
<div className="flex flex-col gap-2">
|
||||||
title="카드형 보기"
|
{notices.map((post) => (
|
||||||
>
|
<PostListItem key={post.id} post={post} />
|
||||||
<LayoutGrid size={18} />
|
))}
|
||||||
</button>
|
</div>
|
||||||
<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>
|
|
||||||
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.content.length === 0 && (
|
{/* 🕒 2. 최신 게시글 섹션 (카드 형태) */}
|
||||||
<div className="text-center py-20 text-gray-500 bg-gray-50 rounded-lg">
|
<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>
|
||||||
)}
|
|
||||||
|
|
||||||
{data && data.content.length > 0 && (
|
{latestPosts.length > 0 ? (
|
||||||
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<button
|
{/* 첫 번째 글은 강조를 위해 크게 보여줄 수도 있지만, 여기선 균일하게 배치 */}
|
||||||
onClick={handlePrevPage}
|
{latestPosts.map((post) => (
|
||||||
disabled={page === 0}
|
<div key={post.id} className="h-full">
|
||||||
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
|
<PostCard post={post} />
|
||||||
aria-label="이전 페이지"
|
</div>
|
||||||
>
|
))}
|
||||||
<ChevronLeft size={24} />
|
</div>
|
||||||
</button>
|
) : (
|
||||||
|
<div className="text-center py-10 bg-gray-50 rounded-xl text-gray-400">
|
||||||
|
아직 작성된 글이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-gray-600">
|
{/* 🔥 3. 인기 게시글 섹션 (카드 형태) */}
|
||||||
Page <span className="text-gray-900 font-bold">{page + 1}</span> {data.totalPages > 0 && `/ ${data.totalPages}`}
|
<section className="animate-in fade-in slide-in-from-bottom-4 duration-700 delay-200">
|
||||||
</span>
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Flame className="text-orange-500" size={20} />
|
||||||
<button
|
<h2 className="text-xl font-bold text-gray-800">인기 포스트</h2>
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 하단 여백 및 아카이브 링크 배너 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/app/signup/page.tsx
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -59,7 +58,8 @@ export default function SignupPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-12">
|
// 🎨 배경색 수정: bg-gray-50 -> bg-white
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white px-4 py-12">
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@@ -137,7 +137,7 @@ export default function SignupPage() {
|
|||||||
disabled={loading}
|
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"
|
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 ? '처리 중...' : '인증 완료'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { getProfile, updateProfile } from '@/api/profile';
|
|||||||
import { uploadImage } from '@/api/image';
|
import { uploadImage } from '@/api/image';
|
||||||
import {
|
import {
|
||||||
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen,
|
||||||
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion
|
Edit3, Camera, Save, XCircle, Plus, Trash2, Move, Settings, FileQuestion,
|
||||||
|
Archive
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import toast from 'react-hot-toast'; // 🎨 Toast 추가
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
const findCategoryNameById = (categories: Category[], id: number): string | undefined => {
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
@@ -41,6 +42,28 @@ interface CategoryItemProps {
|
|||||||
|
|
||||||
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
||||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||||
|
|
||||||
|
// 🆕 1. 하위 항목 중에 현재 활성화된 페이지가 있는지 확인 (재귀 체크)
|
||||||
|
const hasActiveChild = useMemo(() => {
|
||||||
|
const check = (cats: Category[] | undefined): boolean => {
|
||||||
|
if (!cats) return false;
|
||||||
|
return cats.some(c =>
|
||||||
|
decodeURIComponent(pathname) === `/category/${c.name}` || check(c.children)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return check(category.children);
|
||||||
|
}, [category.children, pathname]);
|
||||||
|
|
||||||
|
// 🆕 2. 펼침 상태 관리 (자신이 활성화됐거나 하위가 활성화됐으면 기본값 true)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isActive || hasActiveChild);
|
||||||
|
|
||||||
|
// 🆕 3. 페이지 이동으로 활성화 상태가 바뀌면 자동으로 펼치기
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive || hasActiveChild) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
}, [isActive, hasActiveChild]);
|
||||||
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
@@ -79,7 +102,6 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 하위 카테고리도 ID 순으로 정렬
|
|
||||||
const sortedChildren = useMemo(() => {
|
const sortedChildren = useMemo(() => {
|
||||||
if (!category.children) return [];
|
if (!category.children) return [];
|
||||||
return [...category.children].sort((a, b) => a.id - b.id);
|
return [...category.children].sort((a, b) => a.id - b.id);
|
||||||
@@ -133,12 +155,27 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditMode && category.children && category.children.length > 0 && (
|
{!isEditMode && category.children && category.children.length > 0 && (
|
||||||
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // Link 이동 막기
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-200/50 rounded-full transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={clsx(
|
||||||
|
"text-gray-400 transition-transform duration-200",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedChildren.length > 0 && (
|
{isExpanded && sortedChildren.length > 0 && (
|
||||||
<div className="border-l-2 border-gray-100 ml-4">
|
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
|
||||||
{sortedChildren.map((child) => (
|
{sortedChildren.map((child) => (
|
||||||
<CategoryItem
|
<CategoryItem
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -187,7 +224,6 @@ export default function Sidebar() {
|
|||||||
queryFn: getCategories,
|
queryFn: getCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 최상위 카테고리 ID 순 정렬
|
|
||||||
const sortedCategories = useMemo(() => {
|
const sortedCategories = useMemo(() => {
|
||||||
if (!categories) return undefined;
|
if (!categories) return undefined;
|
||||||
return [...categories].sort((a, b) => a.id - b.id);
|
return [...categories].sort((a, b) => a.id - b.id);
|
||||||
@@ -234,8 +270,6 @@ export default function Sidebar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleAddCategory = (parentId: number | null) => {
|
const handleAddCategory = (parentId: number | null) => {
|
||||||
// 🎨 Prompt 대신 간단한 로직 유지 (복잡도 증가 방지)
|
|
||||||
// 실제로는 모달로 바꾸는게 좋지만, 여기선 일단 Toast만 적용
|
|
||||||
const name = prompt('새 카테고리 이름을 입력하세요:');
|
const name = prompt('새 카테고리 이름을 입력하세요:');
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
createCategoryMutation.mutate({ name, parentId });
|
createCategoryMutation.mutate({ name, parentId });
|
||||||
@@ -332,7 +366,6 @@ export default function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
||||||
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
|
||||||
{/* 🛠️ 수정됨: 로딩 중일 때는 이미지 대신 스켈레톤 표시 */}
|
|
||||||
{isProfileLoading ? (
|
{isProfileLoading ? (
|
||||||
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
@@ -360,8 +393,30 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
<nav className="flex-1 px-4 py-2 flex flex-col">
|
<nav className="flex-1 px-4 py-2 flex flex-col">
|
||||||
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
<div className={clsx('flex flex-col items-center gap-4 mt-4', isOpen && 'hidden')}><Folder size={24} className="text-gray-400" /></div>
|
||||||
|
|
||||||
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
<div className={clsx('space-y-1 flex-1', !isOpen && 'md:hidden')}>
|
||||||
<div className="flex items-center justify-between px-4 mb-3 mt-4 h-8">
|
|
||||||
|
{/* 🆕 2. 아카이브 링크 (페이지 이동) - 위로 이동 */}
|
||||||
|
<div className="mb-4 mt-2">
|
||||||
|
<Link
|
||||||
|
href="/archive"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2.5 px-4 py-2 text-sm rounded-lg transition-all group',
|
||||||
|
pathname === '/archive'
|
||||||
|
? 'bg-blue-50 text-blue-600 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Archive size={16} />
|
||||||
|
<span>Archives</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 추가 */}
|
||||||
|
<div className="border-t border-gray-100 mb-4" />
|
||||||
|
|
||||||
|
{/* 1. 카테고리 섹션 */}
|
||||||
|
<div className="flex items-center justify-between px-4 mb-3 h-8">
|
||||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Categories</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -380,7 +435,7 @@ export default function Sidebar() {
|
|||||||
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
{!categories && <div className="space-y-2 px-4">{[1, 2, 3].map((i) => <div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />)}</div>}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx("min-h-[100px] pb-10 transition-colors rounded-lg", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
className={clsx("min-h-[50px] transition-colors rounded-lg mb-6", isCategoryEditMode && "border-2 border-dashed", isRootDragOver ? "border-blue-500 bg-blue-50" : "border-transparent")}
|
||||||
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
onDragOver={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(true); e.dataTransfer.dropEffect = 'move'; } : undefined}
|
||||||
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
||||||
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
||||||
@@ -405,7 +460,6 @@ export default function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
|
||||||
|
|
||||||
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
||||||
function getSummary(content?: string) {
|
function getSummary(content?: string) {
|
||||||
@@ -17,14 +18,29 @@ export default function PostCard({ post }: { post: Post }) {
|
|||||||
// 요약문 생성
|
// 요약문 생성
|
||||||
const summary = getSummary(post.content);
|
const summary = getSummary(post.content);
|
||||||
|
|
||||||
|
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||||
|
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
||||||
<article className="flex flex-col h-full bg-white rounded-2xl p-6 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)] hover:-translate-y-1 transition-all duration-300 border border-gray-100">
|
<article className={clsx(
|
||||||
|
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border",
|
||||||
|
// 공지글이면 테두리에 살짝 붉은 기운을 줌
|
||||||
|
isNotice
|
||||||
|
? "border-red-100 shadow-[0_2px_8px_rgba(239,68,68,0.08)] hover:shadow-[0_8px_24px_rgba(239,68,68,0.12)]"
|
||||||
|
: "border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)]",
|
||||||
|
"hover:-translate-y-1"
|
||||||
|
)}>
|
||||||
|
|
||||||
{/* 상단 카테고리 & 날짜 */}
|
{/* 상단 카테고리 & 날짜 */}
|
||||||
<div className="flex items-center justify-between text-xs mb-4">
|
<div className="flex items-center justify-between text-xs mb-4">
|
||||||
<span className="px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 font-medium">
|
<span className={clsx(
|
||||||
{post.categoryName}
|
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||||
|
isNotice
|
||||||
|
? "bg-red-50 text-red-600 font-bold border border-red-100" // 🔴 공지 스타일
|
||||||
|
: "bg-slate-100 text-slate-600" // 기본 스타일
|
||||||
|
)}>
|
||||||
|
{isNotice && '📢 '}{post.categoryName}
|
||||||
</span>
|
</span>
|
||||||
<time className="text-gray-400 font-light">
|
<time className="text-gray-400 font-light">
|
||||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||||
@@ -32,18 +48,24 @@ export default function PostCard({ post }: { post: Post }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<h2 className="text-xl font-bold text-gray-800 mb-3 group-hover:text-blue-600 transition-colors line-clamp-2">
|
<h2 className={clsx(
|
||||||
|
"text-xl font-bold mb-3 transition-colors line-clamp-2",
|
||||||
|
isNotice ? "text-gray-900 group-hover:text-red-600" : "text-gray-800 group-hover:text-blue-600"
|
||||||
|
)}>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* 요약글 (이제 실제 데이터가 나옵니다) */}
|
{/* 요약글 */}
|
||||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||||
{summary}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 하단 정보 */}
|
{/* 하단 정보 */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||||
<span className="text-xs font-medium text-blue-500 flex items-center gap-1">
|
<span className={clsx(
|
||||||
|
"text-xs font-medium flex items-center gap-1",
|
||||||
|
isNotice ? "text-red-500" : "text-blue-500"
|
||||||
|
)}>
|
||||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,23 +2,42 @@ import Link from 'next/link';
|
|||||||
import { Post } from '@/types';
|
import { Post } from '@/types';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Eye } from 'lucide-react';
|
import { Eye } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
interface PostListItemProps {
|
interface PostListItemProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostListItem({ post }: PostListItemProps) {
|
export default function PostListItem({ post }: PostListItemProps) {
|
||||||
|
// 📢 공지 카테고리 여부 확인
|
||||||
|
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/posts/${post.slug}`} className="block group">
|
<Link href={`/posts/${post.slug}`} className="block group">
|
||||||
<div className="flex items-center justify-between py-4 border-b border-gray-100 hover:bg-gray-50 px-4 -mx-4 rounded-lg transition-colors">
|
<div className={clsx(
|
||||||
|
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors",
|
||||||
|
isNotice
|
||||||
|
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게
|
||||||
|
: "border-gray-100 hover:bg-gray-50"
|
||||||
|
)}>
|
||||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
{/* 카테고리 라벨 (작은 화면에선 숨김 처리 가능) */}
|
{/* 카테고리 라벨 */}
|
||||||
<span className="hidden sm:inline-block px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 text-xs font-medium whitespace-nowrap">
|
<span className={clsx(
|
||||||
{post.categoryName}
|
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap",
|
||||||
|
isNotice
|
||||||
|
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조
|
||||||
|
: "bg-slate-100 text-slate-600"
|
||||||
|
)}>
|
||||||
|
{isNotice && '📢 '}{post.categoryName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<h3 className="text-base font-medium text-gray-800 truncate group-hover:text-blue-600 transition-colors">
|
<h3 className={clsx(
|
||||||
|
"text-base font-medium truncate transition-colors",
|
||||||
|
isNotice
|
||||||
|
? "text-gray-900 group-hover:text-red-600 font-semibold"
|
||||||
|
: "text-gray-800 group-hover:text-blue-600"
|
||||||
|
)}>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user