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:
@@ -11,12 +11,13 @@ import { getProfile, updateProfile } from '@/api/profile';
|
||||
import { uploadImage } from '@/api/image';
|
||||
import {
|
||||
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';
|
||||
import { clsx } from 'clsx';
|
||||
import { Profile, ProfileUpdateRequest, Category } from '@/types';
|
||||
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 => {
|
||||
for (const cat of categories) {
|
||||
@@ -41,6 +42,28 @@ interface CategoryItemProps {
|
||||
|
||||
function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, onDelete }: CategoryItemProps) {
|
||||
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 handleDragStart = (e: React.DragEvent) => {
|
||||
@@ -79,7 +102,6 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 하위 카테고리도 ID 순으로 정렬
|
||||
const sortedChildren = useMemo(() => {
|
||||
if (!category.children) return [];
|
||||
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 && (
|
||||
<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>
|
||||
|
||||
{sortedChildren.length > 0 && (
|
||||
<div className="border-l-2 border-gray-100 ml-4">
|
||||
{isExpanded && sortedChildren.length > 0 && (
|
||||
<div className="border-l-2 border-gray-100 ml-4 animate-in slide-in-from-top-1 duration-200 fade-in">
|
||||
{sortedChildren.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
@@ -172,7 +209,7 @@ export default function Sidebar() {
|
||||
|
||||
const [isCategoryEditMode, setIsCategoryEditMode] = useState(false);
|
||||
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
||||
|
||||
|
||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -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() {
|
||||
)}
|
||||
<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">
|
||||
{/* 🛠️ 수정됨: 로딩 중일 때는 이미지 대신 스켈레톤 표시 */}
|
||||
{isProfileLoading ? (
|
||||
<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">
|
||||
<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="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>
|
||||
{isAdmin && (
|
||||
<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>}
|
||||
|
||||
<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}
|
||||
onDragLeave={isCategoryEditMode ? (e) => { e.preventDefault(); setIsRootDragOver(false); } : undefined}
|
||||
onDrop={isCategoryEditMode ? handleRootDrop : undefined}
|
||||
@@ -405,7 +460,6 @@ export default function Sidebar() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCategoryEditMode && categories?.length === 0 && <div className="text-center text-xs text-gray-400 py-4">+ 버튼을 눌러 카테고리를 추가하세요.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Post } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
|
||||
|
||||
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
||||
function getSummary(content?: string) {
|
||||
@@ -16,15 +17,30 @@ function getSummary(content?: string) {
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
// 요약문 생성
|
||||
const summary = getSummary(post.content);
|
||||
|
||||
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 font-medium">
|
||||
{post.categoryName}
|
||||
<span className={clsx(
|
||||
"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>
|
||||
<time className="text-gray-400 font-light">
|
||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||
@@ -32,18 +48,24 @@ export default function PostCard({ post }: { post: Post }) {
|
||||
</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}
|
||||
</h2>
|
||||
|
||||
{/* 요약글 (이제 실제 데이터가 나옵니다) */}
|
||||
{/* 요약글 */}
|
||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,42 @@ import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
// 📢 공지 카테고리 여부 확인
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 카테고리 라벨 (작은 화면에선 숨김 처리 가능) */}
|
||||
<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">
|
||||
{post.categoryName}
|
||||
{/* 카테고리 라벨 */}
|
||||
<span className={clsx(
|
||||
"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>
|
||||
|
||||
{/* 제목 */}
|
||||
<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}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user