This commit is contained in:
pwy3282040@msecure.co
2025-12-27 00:17:46 +09:00
commit 87405e897e
34 changed files with 6264 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { getCategories } from '@/api/category';
import { Github, Mail, Menu, X, ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { clsx } from 'clsx';
export default function Sidebar() {
const [isOpen, setIsOpen] = useState(true); // 사이드바 열림/닫힘 상태
const pathname = usePathname();
// 1. 서버에서 카테고리 데이터 가져오기
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
});
return (
<>
{/* 📱 모바일용 메뉴 토글 버튼 (화면 왼쪽 위에 고정) */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-full shadow-md md:hidden hover:bg-gray-100 transition-colors"
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* 🖥️ 사이드바 본체 */}
<aside
className={clsx(
// 기본 스타일 & 애니메이션
'fixed top-0 left-0 z-40 h-screen bg-white border-r border-gray-100 transition-all duration-300 ease-in-out overflow-y-auto scrollbar-hide',
// 열렸을 때 vs 닫혔을 때 너비 조절
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
'flex flex-col'
)}
>
{/* A. 프로필 영역 */}
<div className={clsx('p-6 text-center transition-opacity duration-200', !isOpen && 'md:opacity-0 md:hidden')}>
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50">
{/* 프로필 이미지 (임시) */}
<img
src="https://api.dicebear.com/7.x/notionists/svg?seed=Felix"
alt="Profile"
className="w-full h-full object-cover"
/>
</div>
<h2 className="text-xl font-bold text-gray-800">Dev Park</h2>
<p className="text-sm text-gray-500 mt-1"> </p>
<p className="text-xs text-gray-400 mt-3 font-light leading-relaxed">
"코드로 세상을 바꾸고 싶은<br />박개발의 기술 블로그입니다."
</p>
</div>
{/* B. 네비게이션 & 카테고리 */}
<nav className="flex-1 px-4 py-2">
{/* 닫혔을 때(좁은 모드) 메뉴 아이콘 표시 */}
<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', !isOpen && 'md:hidden')}>
<p className="px-4 text-xs font-bold text-gray-400 uppercase tracking-wider mb-3 mt-4">
Categories
</p>
{/* 로딩 중일 때 스켈레톤 UI */}
{!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?.map((cat) => {
// 현재 카테고리(또는 자식)가 선택되었는지 확인
const isActive = pathname.includes(`/category/${cat.id}`);
return (
<div key={cat.id} className="mb-1">
{/* 1차 카테고리 */}
<Link
href={`/category/${cat.id}`}
className={clsx(
'flex items-center justify-between px-4 py-2.5 text-sm font-medium rounded-lg transition-all group',
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
)}
>
<div className="flex items-center gap-2.5">
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
<span>{cat.name}</span>
</div>
{cat.children && cat.children.length > 0 && (
<ChevronRight size={14} className={clsx("text-gray-300 transition-transform", isActive && "rotate-90")} />
)}
</Link>
{/* 2차 카테고리 (자식이 있을 경우) */}
{cat.children && cat.children.length > 0 && (
<div className="ml-5 mt-1 space-y-0.5 border-l-2 border-gray-100 pl-2">
{cat.children.map((child) => {
const isChildActive = pathname.includes(`/category/${child.id}`);
return (
<Link
key={child.id}
href={`/category/${child.id}`}
className={clsx(
"block px-3 py-2 text-sm rounded-md transition-colors",
isChildActive
? "text-blue-600 font-medium bg-blue-50/50"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
)}
>
- {child.name}
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
</nav>
{/* C. 광고 영역 (거슬리지 않게 하단 배치) */}
<div className={clsx('px-6 pb-6', !isOpen && 'hidden')}>
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl border border-dashed border-gray-200 text-center relative overflow-hidden group cursor-pointer hover:border-blue-200 transition-colors">
<div className="absolute top-0 right-0 p-1">
<span className="text-[9px] bg-gray-200 text-gray-500 px-1 rounded">AD</span>
</div>
<p className="text-xs text-blue-500 font-semibold mb-1">AWS Cloud School</p>
<p className="text-[11px] text-gray-500">
<br/>
<span className="underline group-hover:text-blue-600"> &rarr;</span>
</p>
</div>
</div>
{/* D. 소셜 링크 (최하단) */}
<div className={clsx('p-6 border-t border-gray-100 bg-white', !isOpen && 'hidden')}>
<div className="flex justify-center gap-3">
<a
href="https://github.com"
target="_blank"
rel="noreferrer"
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-gray-800 hover:text-white transition-all shadow-sm hover:-translate-y-1"
aria-label="Github"
>
<Github size={18} />
</a>
<a
href="mailto:user@example.com"
className="p-2.5 text-gray-500 bg-gray-50 rounded-full hover:bg-blue-500 hover:text-white transition-all shadow-sm hover:-translate-y-1"
aria-label="Email"
>
<Mail size={18} />
</a>
</div>
<p className="text-center text-[10px] text-gray-300 mt-4 font-light">
© 2024 Dev Park. All rights reserved.
</p>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,73 @@
// src/components/layout/TopHeader.tsx
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { useEffect, useState } from 'react';
import { LogOut, PenLine, User, UserPlus } from 'lucide-react';
export default function TopHeader() {
const router = useRouter();
const { isLoggedIn, role, logout } = useAuthStore(); // 👈 role 가져오기
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleLogout = () => {
if (confirm('로그아웃 하시겠습니까?')) {
logout();
alert('로그아웃 되었습니다.');
router.push('/');
}
};
if (!mounted) return null;
return (
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
{isLoggedIn ? (
<>
{/* 👇 관리자(ADMIN)일 때만 글쓰기 버튼 노출 */}
{role && role.includes('ADMIN') && (
<Link
href="/write"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
>
<PenLine size={16} />
<span></span>
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-600 text-sm font-medium rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-red-500 transition-colors"
>
<LogOut size={16} />
<span className="hidden sm:inline"></span>
</button>
</>
) : (
<>
<Link
href="/login"
className="flex items-center gap-2 px-4 py-2 text-gray-500 text-sm font-medium hover:text-blue-600 transition-colors"
>
<User size={18} />
<span></span>
</Link>
<Link
href="/signup"
className="flex items-center gap-2 px-4 py-2 bg-white text-blue-600 text-sm font-bold rounded-full border border-blue-100 shadow-sm hover:bg-blue-50 hover:shadow-md transition-all"
>
<UserPlus size={16} />
<span></span>
</Link>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,14 @@
// src/components/post/MarkdownRenderer.tsx
import ReactMarkdown from 'react-markdown';
interface MarkdownRendererProps {
content: string;
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return (
<div className="prose prose-slate max-w-none prose-headings:font-bold prose-a:text-blue-600 hover:prose-a:text-blue-500 prose-img:rounded-xl">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Post } from '@/types';
import Link from 'next/link';
import { format } from 'date-fns';
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
function getSummary(content?: string) {
if (!content) return '';
return content
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
.substring(0, 120); // 120자까지만 자르기
}
export default function PostCard({ post }: { post: Post }) {
// 요약문 생성
const summary = getSummary(post.content);
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">
{/* 상단 카테고리 & 날짜 */}
<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>
<time className="text-gray-400 font-light">
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
</time>
</div>
{/* 제목 */}
<h2 className="text-xl font-bold text-gray-800 mb-3 group-hover:text-blue-600 transition-colors line-clamp-2">
{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">
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>By Dev Park</span>
</div>
<span className="text-xs font-medium text-blue-500 flex items-center gap-1">
Read more <span className="group-hover:translate-x-1 transition-transform"></span>
</span>
</div>
</article>
</Link>
);
}