.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m30s

This commit is contained in:
wypark
2026-05-28 21:45:30 +09:00
parent 58a012621a
commit 0273cae6e4
37 changed files with 2625 additions and 1319 deletions

View File

@@ -50,14 +50,14 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
if (!_hasHydrated || !isAdmin) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} />
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div>
);
}
return (
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4">
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴">
<div className="mx-auto max-w-7xl space-y-6 px-1 py-4 md:px-4">
<nav className="flex gap-2 overflow-x-auto border-b border-[var(--color-line)] pb-3" aria-label="관리자 메뉴">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
@@ -69,8 +69,8 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
className={clsx(
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
isActive
? 'bg-gray-950 text-white'
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950',
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
: 'border border-[var(--color-line)] bg-white/70 text-[var(--color-text-muted)] hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10',
)}
>
<Icon size={16} />

View File

@@ -0,0 +1,75 @@
import Link from 'next/link';
import { AlertCircle, ArrowRight, MessageSquareText, RefreshCcw, Tags } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
import { DashboardActionItems } from '@/types';
interface AdminDashboardActionCenterProps {
actionItems?: DashboardActionItems;
isFallback: boolean;
}
export default function AdminDashboardActionCenter({
actionItems,
isFallback,
}: AdminDashboardActionCenterProps) {
const items = [
{
label: '답변 필요한 댓글',
value: actionItems?.unansweredComments,
href: '/admin/comments',
icon: MessageSquareText,
},
{
label: '미분류 글',
value: actionItems?.uncategorizedPosts,
href: '/admin/posts',
icon: Tags,
},
{
label: '오래 방치된 인기 글',
value: actionItems?.stalePopularPosts,
href: '/admin/posts',
icon: RefreshCcw,
},
];
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<AlertCircle size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
{actionItems ? (
<div className="space-y-2">
{items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
className="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
>
<span className="flex items-center gap-3 text-sm font-semibold text-[var(--color-text-muted)]">
<Icon size={16} />
{item.label}
</span>
<span className="flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
{(item.value ?? 0).toLocaleString()}
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
</span>
</Link>
);
})}
</div>
) : (
<EmptyState
title="액션 집계 연결 전"
description={isFallback ? '관리자 대시보드 API가 준비되면 처리할 일을 모아 보여줍니다.' : '처리할 항목이 없습니다.'}
/>
)}
</Surface>
);
}

View File

@@ -0,0 +1,69 @@
import Link from 'next/link';
import { ArrowRight, FolderTree } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
import { DashboardCategoryStat } from '@/types';
interface AdminDashboardCategoryHealthProps {
categoryStats: DashboardCategoryStat[];
isFallback: boolean;
}
const formatDate = (value?: string | null) => {
if (!value) return '발행 기록 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '발행 기록 없음';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
};
export default function AdminDashboardCategoryHealth({
categoryStats,
isFallback,
}: AdminDashboardCategoryHealthProps) {
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<FolderTree size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
{categoryStats.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-[var(--color-line)]">
<div className="min-w-[680px]">
<div className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 bg-black/[0.03] px-4 py-3 text-xs font-bold text-[var(--color-text-subtle)] dark:bg-white/10">
<span></span>
<span></span>
<span> </span>
<span> </span>
<span />
</div>
{categoryStats.slice(0, 6).map((category) => (
<Link
key={category.id}
href={`/category/${category.name}`}
className="grid grid-cols-[1.2fr_0.6fr_0.7fr_0.9fr_auto] gap-3 border-t border-[var(--color-line)] px-4 py-3 text-sm transition hover:bg-black/[0.03] dark:hover:bg-white/10"
>
<span className="truncate font-semibold text-[var(--color-text)]">{category.name}</span>
<span className="text-[var(--color-text-muted)]">{category.postCount.toLocaleString()}</span>
<span className="text-[var(--color-text-muted)]">{category.recentViewCount.toLocaleString()}</span>
<span className="truncate text-[var(--color-text-subtle)]">{formatDate(category.lastPublishedAt)}</span>
<ArrowRight size={15} className="text-[var(--color-text-subtle)]" />
</Link>
))}
</div>
</div>
) : (
<EmptyState
title={isFallback ? '카테고리 통계 API 연결 전' : '카테고리 통계가 없습니다.'}
description={isFallback ? '글 수, 최근 조회수, 최근 발행일은 dashboard API 연결 후 표시됩니다.' : undefined}
/>
)}
</Surface>
);
}

View File

@@ -0,0 +1,82 @@
import { CalendarClock, Eye, FileText, FolderTree, MessageSquareText } from 'lucide-react';
import MetricCard from '@/components/ui/MetricCard';
import { DashboardOverview } from '@/types';
interface AdminDashboardOverviewProps {
overview?: DashboardOverview;
fallbackTotals: {
totalPosts: number;
totalComments: number;
totalCategories: number;
lastPublishedAt?: string;
};
}
const formatDateTime = (value?: string | null) => {
if (!value) return '최근 발행 정보 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '최근 발행 정보 없음';
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
export default function AdminDashboardOverview({
overview,
fallbackTotals,
}: AdminDashboardOverviewProps) {
const totalPosts = overview?.totalPosts ?? fallbackTotals.totalPosts;
const totalComments = overview?.totalComments ?? fallbackTotals.totalComments;
const totalCategories = overview?.totalCategories ?? fallbackTotals.totalCategories;
const lastPublishedAt = overview?.lastPublishedAt ?? fallbackTotals.lastPublishedAt;
return (
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard
label="오늘 조회수"
value={overview?.todayViews.value ?? null}
changeRate={overview?.todayViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard
label="최근 7일 조회수"
value={overview?.weekViews.value ?? null}
changeRate={overview?.weekViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard
label="최근 30일 조회수"
value={overview?.monthViews.value ?? null}
changeRate={overview?.monthViews.changeRate}
helper="백엔드 집계가 준비되면 표시됩니다."
disabled={!overview}
icon={<Eye size={18} />}
/>
<MetricCard label="총 게시글" value={totalPosts} icon={<FileText size={18} />} />
<MetricCard label="총 댓글" value={totalComments} icon={<MessageSquareText size={18} />} />
<MetricCard
label="카테고리"
value={totalCategories}
helper={formatDateTime(lastPublishedAt)}
icon={<FolderTree size={18} />}
/>
{overview?.generatedAt && (
<div className="sm:col-span-2 xl:col-span-3">
<p className="inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
<CalendarClock size={14} />
{formatDateTime(overview.generatedAt)}
</p>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import Link from 'next/link';
import { ArrowRight, Flame, TrendingUp } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import { DashboardPostStat } from '@/types';
interface AdminDashboardPostPerformanceProps {
topPosts: DashboardPostStat[];
risingPosts: DashboardPostStat[];
stalePopularPosts: DashboardPostStat[];
isFallback: boolean;
}
function PostStatRow({ post, isFallback }: { post: DashboardPostStat; isFallback: boolean }) {
return (
<Link
href={`/posts/${post.slug}`}
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<StatusBadge>{post.categoryName || '미분류'}</StatusBadge>
<span className="text-xs text-[var(--color-text-subtle)]">
{isFallback ? '누적 조회' : '기간 조회'} {(isFallback ? post.viewCount : post.rangeViewCount).toLocaleString()}
</span>
</div>
<p className="line-clamp-1 text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</p>
</div>
<ArrowRight size={15} className="mt-5 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" />
</Link>
);
}
export default function AdminDashboardPostPerformance({
topPosts,
risingPosts,
stalePopularPosts,
isFallback,
}: AdminDashboardPostPerformanceProps) {
const secondaryPosts = risingPosts.length > 0 ? risingPosts : stalePopularPosts;
return (
<Surface as="section" className="p-5">
<div className="mb-5 flex items-center gap-2">
<Flame size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<Flame size={16} />
</div>
{topPosts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{topPosts.slice(0, 5).map((post) => (
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
))}
</div>
) : (
<EmptyState title="인기 글 데이터가 없습니다." className="min-h-40" />
)}
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[var(--color-text)]">
<TrendingUp size={16} />
{risingPosts.length > 0 ? '상승 중인 글' : '관리 후보 글'}
</div>
{secondaryPosts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{secondaryPosts.slice(0, 5).map((post) => (
<PostStatRow key={post.id} post={post} isFallback={isFallback} />
))}
</div>
) : (
<EmptyState
title={isFallback ? '통계 API 연결 전' : '관리 후보가 없습니다.'}
description={isFallback ? '상승/방치 기준은 dashboard API 연결 후 표시됩니다.' : undefined}
className="min-h-40"
/>
)}
</div>
</div>
</Surface>
);
}

View File

@@ -0,0 +1,85 @@
import { BarChart3 } from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import SegmentedControl from '@/components/ui/SegmentedControl';
import Surface from '@/components/ui/Surface';
import { DashboardRange, DashboardTrafficPoint } from '@/types';
interface AdminDashboardTrafficChartProps {
points: DashboardTrafficPoint[];
range: DashboardRange;
onRangeChange: (range: DashboardRange) => void;
isFallback: boolean;
}
const rangeOptions = [
{ label: '7일', value: '7d' },
{ label: '30일', value: '30d' },
{ label: '90일', value: '90d' },
] satisfies { label: string; value: DashboardRange }[];
const formatDay = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
month: 'numeric',
day: 'numeric',
}).format(date);
};
export default function AdminDashboardTrafficChart({
points,
range,
onRangeChange,
isFallback,
}: AdminDashboardTrafficChartProps) {
const maxViews = Math.max(...points.map((point) => point.views), 1);
return (
<Surface as="section" className="p-5">
<div className="mb-6 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
<div className="flex items-center gap-2">
<BarChart3 size={19} className="text-[var(--color-accent)]" />
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
.
</p>
</div>
<SegmentedControl
ariaLabel="트래픽 기간"
options={rangeOptions}
value={range}
onChange={onRangeChange}
/>
</div>
{points.length > 0 ? (
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-white/50 px-3 py-4 dark:bg-white/5">
{points.map((point) => {
const height = Math.max((point.views / maxViews) * 100, 4);
return (
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
style={{ height: `${height}%` }}
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
/>
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
{formatDay(point.date)}
</span>
</div>
);
})}
</div>
) : (
<EmptyState
title="통계 API 연결 전"
description={isFallback ? '백엔드 집계가 준비되면 기간별 그래프가 표시됩니다.' : '표시할 트래픽 데이터가 없습니다.'}
/>
)}
</Surface>
);
}

View File

@@ -5,7 +5,6 @@ import { useAuthStore } from '@/store/authStore';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createComment } from '@/api/comments';
import { Loader2, Send } from 'lucide-react';
import { clsx } from 'clsx';
interface CommentFormProps {
postSlug: string;
@@ -15,7 +14,7 @@ interface CommentFormProps {
}
export default function CommentForm({ postSlug, parentId = null, onSuccess, placeholder = '댓글을 남겨보세요.' }: CommentFormProps) {
const { isLoggedIn, role } = useAuthStore();
const { isLoggedIn } = useAuthStore();
const queryClient = useQueryClient();
const [content, setContent] = useState('');
@@ -29,8 +28,16 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
queryClient.invalidateQueries({ queryKey: ['comments', postSlug] });
if (onSuccess) onSuccess();
},
onError: (err: any) => {
alert('댓글 작성 실패: ' + (err.response?.data?.message || err.message));
onError: (error: unknown) => {
const responseError = error as {
response?: {
data?: {
message?: string;
};
};
};
const fallbackMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
alert('댓글 작성 실패: ' + (responseError.response?.data?.message || fallbackMessage));
},
});
@@ -98,4 +105,4 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
</div>
</form>
);
}
}

View File

@@ -5,6 +5,7 @@ import { getComments } from '@/api/comments';
import CommentForm from './CommentForm';
import CommentItem from './CommentItem';
import { Loader2, MessageCircle } from 'lucide-react';
import { Comment } from '@/types';
interface CommentListProps {
postSlug: string;
@@ -18,7 +19,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
});
// 총 댓글 수 계산 (재귀)
const countComments = (list: any[]): number => {
const countComments = (list: Comment[]): number => {
if (!list) return 0;
return list.reduce((acc, curr) => acc + 1 + countComments(curr.children), 0);
};
@@ -61,4 +62,4 @@ export default function CommentList({ postSlug }: CommentListProps) {
</div>
</div>
);
}
}

View File

@@ -65,7 +65,9 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
<div
className={clsx(
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
isActive ? 'bg-blue-50 font-medium text-blue-600' : 'text-gray-600 hover:bg-gray-50',
isActive
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
style={{ marginLeft: `${depth * 10}px` }}
>
@@ -82,19 +84,19 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
event.stopPropagation();
setIsExpanded((previous) => !previous);
}}
className="ml-1 rounded-full p-1 transition-colors hover:bg-gray-200/50"
className="ml-1 rounded-full p-1 transition-colors hover:bg-black/[0.06] dark:hover:bg-white/10"
aria-label={isChildrenVisible ? `${category.name} 접기` : `${category.name} 펼치기`}
>
<ChevronRight
size={14}
className={clsx('text-gray-400 transition-transform duration-200', isChildrenVisible && 'rotate-90')}
className={clsx('text-[var(--color-text-subtle)] transition-transform duration-200', isChildrenVisible && 'rotate-90')}
/>
</button>
)}
</div>
{isChildrenVisible && hasChildren && (
<div className="ml-4 border-l-2 border-gray-100">
<div className="ml-4 border-l border-[var(--color-line)]">
{sortedChildren.map((child) => (
<CategoryItem
key={child.id}
@@ -147,7 +149,7 @@ function SidebarContent() {
<button
type="button"
onClick={() => setIsOpen((previous) => !previous)}
className="fixed left-4 top-4 z-50 rounded-full bg-white p-2 shadow-md transition-colors hover:bg-gray-100 md:hidden"
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-surface-strong)] p-2 shadow-md transition-colors hover:bg-white md:hidden"
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
@@ -155,15 +157,15 @@ function SidebarContent() {
<aside
className={clsx(
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-gray-100 bg-white transition-all duration-300 ease-in-out',
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]/95 backdrop-blur-xl transition-all duration-300 ease-in-out',
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
)}
>
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
<Link href="/" className="block transition-opacity hover:opacity-80">
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-gray-200 shadow-inner ring-4 ring-gray-50">
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-4 ring-white/70 dark:bg-white/10 dark:ring-white/10">
{isProfileLoading ? (
<div className="h-full w-full animate-pulse bg-gray-200" />
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
) : (
<Image
src={displayProfile.imageUrl || defaultProfile.imageUrl!}
@@ -179,13 +181,13 @@ function SidebarContent() {
{isProfileLoading ? (
<div className="flex flex-col items-center space-y-2">
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
<div className="h-6 w-24 animate-pulse rounded bg-black/[0.06] dark:bg-white/10" />
<div className="h-4 w-32 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
</div>
) : (
<>
<h2 className="text-xl font-bold text-gray-800">{displayProfile.name}</h2>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-gray-500">{displayProfile.bio}</p>
<h2 className="text-xl font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
</>
)}
</Link>
@@ -193,12 +195,12 @@ function SidebarContent() {
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
<Folder size={24} className="text-gray-400" />
<Folder size={24} className="text-[var(--color-text-subtle)]" />
</div>
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
<div className="shrink-0 space-y-1">
<div className="mb-6 mt-2 px-1">
<div id="blog-search" className="mb-6 mt-2 px-1">
<PostSearch
onSearch={handleSearch}
placeholder="검색..."
@@ -212,19 +214,19 @@ function SidebarContent() {
className={clsx(
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
pathname === '/archive'
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-600 hover:bg-gray-50',
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Archive size={16} />
<span>Archives</span>
<span></span>
</Link>
</div>
<div className="mb-4 border-t border-gray-100" />
<div className="mb-4 border-t border-[var(--color-line)]" />
<div className="mb-3 flex h-8 items-center justify-between px-4">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Categories</p>
<p className="text-xs font-bold text-[var(--color-text-subtle)]"></p>
</div>
</div>
@@ -232,7 +234,7 @@ function SidebarContent() {
{!categories && (
<div className="space-y-2 px-4">
{[1, 2, 3].map((item) => (
<div key={item} className="h-8 animate-pulse rounded bg-gray-100" />
<div key={item} className="h-8 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
))}
</div>
)}
@@ -253,8 +255,8 @@ function SidebarContent() {
className={clsx(
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
pathname === '/category/uncategorized'
? 'bg-blue-50 font-medium text-blue-600'
: 'text-gray-600 hover:bg-gray-50',
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<FileQuestion size={16} />
@@ -266,26 +268,26 @@ function SidebarContent() {
</div>
</nav>
<div className={clsx('shrink-0 border-t border-gray-100 bg-white p-6', !isOpen && 'hidden')}>
<div className={clsx('shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-6', !isOpen && 'hidden')}>
<div className="flex justify-center gap-3">
<a
href={displayProfile.githubUrl || '#'}
target="_blank"
rel="noreferrer"
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-gray-800 hover:text-white"
aria-label="Github"
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-text)] hover:text-white dark:bg-white/10 dark:hover:bg-white dark:hover:text-black"
aria-label="GitHub"
>
<Github size={18} />
</a>
<a
href={`mailto:${displayProfile.email}`}
className="rounded-full bg-gray-50 p-2.5 text-gray-500 shadow-sm transition-all hover:bg-blue-500 hover:text-white"
aria-label="Email"
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-accent)] hover:text-white dark:bg-white/10"
aria-label="이메일"
>
<Mail size={18} />
</a>
</div>
<p className="mt-4 text-center text-[10px] font-light text-gray-300">
<p className="mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]">
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
</p>
</div>
@@ -296,7 +298,7 @@ function SidebarContent() {
export default function Sidebar() {
return (
<Suspense fallback={<div className="h-screen w-72 border-r border-gray-100 bg-white" />}>
<Suspense fallback={<div className="h-screen w-72 border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]" />}>
<SidebarContent />
</Suspense>
);

View File

@@ -29,15 +29,15 @@ export default function TopHeader() {
<>
<Link
href="/admin"
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 text-sm font-semibold rounded-full border border-gray-200 shadow-sm hover:bg-gray-50 hover:text-blue-600 transition-colors"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-[var(--color-accent)] dark:bg-white/10"
>
<Settings size={16} />
<span className="hidden sm:inline"> </span>
<span className="hidden sm:inline"></span>
</Link>
<Link
href="/admin/posts/new"
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"
className="flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md"
>
<PenLine size={16} />
<span> </span>
@@ -47,7 +47,7 @@ export default function TopHeader() {
<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"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-red-500 dark:bg-white/10"
>
<LogOut size={16} />
<span className="hidden sm:inline"></span>
@@ -57,7 +57,7 @@ export default function TopHeader() {
<>
<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"
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]"
>
<User size={18} />
<span></span>
@@ -65,7 +65,7 @@ export default function TopHeader() {
<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"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-bold text-[var(--color-accent)] shadow-sm backdrop-blur-xl transition-all hover:bg-white hover:shadow-md dark:bg-white/10"
>
<UserPlus size={16} />
<span></span>

View File

@@ -1,7 +1,8 @@
'use client';
import React, { useState } from 'react';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -15,128 +16,129 @@ interface MarkdownRendererProps {
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
const components: Components = {
// 1. 코드 블록 커스텀
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const codeString = String(children).replace(/\n$/, '');
if (match) {
return (
<CodeBlock language={language} code={codeString} />
);
}
return (
<code
className="mx-1 break-words rounded-md bg-black/[0.05] px-1.5 py-0.5 font-mono text-[0.9em] font-medium text-red-600 dark:bg-white/10 dark:text-red-300"
{...props}
>
{children}
</code>
);
},
// 2. 인용구
blockquote({ children }) {
return (
<blockquote className="my-7 rounded-r-lg border-l-4 border-[var(--color-accent)] bg-[var(--color-accent-soft)] py-3 pl-5 pr-4 text-[var(--color-text-muted)]">
{children}
</blockquote>
);
},
// 3. 링크
a({ href, children }) {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="inline-flex items-center gap-0.5 font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors hover:underline"
>
{children}
{isExternal && <ExternalLink size={12} className="opacity-70" />}
</a>
);
},
// 4. 테이블
table({ children }) {
return (
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
{children}
</table>
</div>
);
},
thead({ children }) {
return <thead className="border-b border-[var(--color-line)] bg-black/[0.03] text-xs text-[var(--color-text-muted)] dark:bg-white/10">{children}</thead>;
},
th({ children }) {
return <th className="px-5 py-3 font-bold text-[var(--color-text)]">{children}</th>;
},
td({ children }) {
return <td className="border-b border-[var(--color-line)] px-5 py-4 whitespace-pre-wrap">{children}</td>;
},
// 5. 이미지
img({ src, alt }) {
return (
<span className="my-8 flex flex-col items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className="h-auto max-h-[700px] max-w-full rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-card)] transition-transform duration-150 hover:scale-[1.005]"
loading="lazy"
onError={(event) => {
event.currentTarget.style.display = 'none';
}}
/>
{alt && <span className="mt-3 block w-full text-center text-sm text-[var(--color-text-subtle)]">{alt}</span>}
</span>
);
},
// 6. 리스트 스타일
ul({ children }) {
return <ul className="my-4 list-disc space-y-2 pl-6 text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]">{children}</ul>;
},
ol({ children, ...props }) {
return (
<ol
className="my-4 list-decimal space-y-2 pl-6 font-medium text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]"
{...props}
>
{children}
</ol>
);
},
li({ children }) {
return <li className="pl-1">{children}</li>;
},
// 7. 헤딩 스타일
h1({ children, ...props }) {
return <h1 className="mt-12 mb-6 border-b border-[var(--color-line)] pb-4 text-3xl font-extrabold tracking-normal text-[var(--color-text)]" {...props}>{children}</h1>;
},
h2({ children, ...props }) {
return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
},
h3({ children, ...props }) {
return <h3 className="mt-9 mb-4 border-l-4 border-[var(--color-accent)] pl-3 text-xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h3>;
},
};
return (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeSlug]}
components={{
// 1. 코드 블록 커스텀
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const codeString = String(children).replace(/\n$/, '');
if (!inline && match) {
return (
<CodeBlock language={language} code={codeString} />
);
}
return (
<code
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
{...props}
>
{children}
</code>
);
},
// 2. 인용구
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
{children}
</blockquote>
);
},
// 3. 링크
a({ href, children }) {
const isExternal = href?.startsWith('http');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
>
{children}
{isExternal && <ExternalLink size={12} className="opacity-70" />}
</a>
);
},
// 4. 테이블
table({ children }) {
return (
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
<table className="w-full text-sm text-left text-gray-700 bg-white">
{children}
</table>
</div>
);
},
thead({ children }) {
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>;
},
th({ children }) {
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>;
},
td({ children }) {
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
},
// 5. 이미지 (비율 유지 및 중앙 정렬)
img({ src, alt }) {
return (
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
// items-center 추가: 가로축 중앙 정렬
<span className="block my-8 flex flex-col items-center justify-center">
<img
src={src}
alt={alt}
className="rounded-xl shadow-lg border border-gray-100 max-w-full h-auto max-h-[700px] mx-auto hover:scale-[1.01] transition-transform duration-300"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
{alt && <span className="block text-center text-sm text-gray-400 mt-2 w-full">{alt}</span>}
</span>
);
},
// 6. 리스트 스타일
ul({ children }) {
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
},
ol({ children, ...props }: any) {
return (
<ol
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
{...props}
>
{children}
</ol>
);
},
li({ children }) {
return <li className="pl-1">{children}</li>;
},
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
h1({ children, ...props }: any) {
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>;
},
h2({ children, ...props }: any) {
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
},
h3({ children, ...props }: any) {
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1" {...props}>{children}</h3>;
},
}}
components={components}
>
{content}
</ReactMarkdown>
@@ -155,7 +157,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
};
return (
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
<div className="group relative my-8 overflow-hidden rounded-lg border border-gray-700/50 bg-[#1e1e1e] shadow-[var(--shadow-card)]">
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
@@ -182,7 +184,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
title="코드 복사"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
<span>{isCopied ? '복사됨' : '복사'}</span>
</button>
</div>
@@ -204,4 +206,4 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
</div>
</div>
);
}
}

View File

@@ -1,75 +1,71 @@
import { Post } from '@/types';
import Link from 'next/link';
import { format } from 'date-fns';
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
import { ChevronRight } from 'lucide-react';
import { Post } from '@/types';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
const isNoticePost = (post: Post) => {
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
};
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
};
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
function getSummary(content?: string) {
if (!content) return '';
if (!content) return '본문을 열어 전체 내용을 확인해 보세요.';
return content
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
.substring(0, 120); // 120자까지만 자르기
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/[#*`_~>]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
export default function PostCard({ post }: { post: Post }) {
// 요약문 생성
const summary = getSummary(post.content);
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
const isNotice = isNoticePost(post);
return (
<Link href={`/posts/${post.slug}`} className="block group h-full">
<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={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')}
<Link href={`/posts/${post.slug}`} className="group block h-full">
<Surface
as="article"
interactive
className="flex h-full flex-col p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
{post.categoryName || '미분류'}
</StatusBadge>
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
</div>
{/* 제목 */}
<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"
)}>
<h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{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 className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{getSummary(post.content)}
</p>
{/* 하단 정보 */}
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
<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>
<div className="mt-6 flex items-center justify-between border-t border-[var(--color-line)] pt-4">
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
</span>
</div>
</article>
</Surface>
</Link>
);
}
}

View File

@@ -9,6 +9,7 @@ import TOC from '@/components/post/TOC';
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { Post } from '@/types'; // 타입 임포트
interface PostDetailClientProps {
@@ -66,18 +67,18 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
const isAuthError = errorStatus === 401 || errorStatus === 403;
return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
<div className="flex justify-center mb-4">
<AlertCircle className="text-gray-300" size={64} />
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
</h2>
<p className="text-gray-500 mb-6">
<p className="mb-6 text-[var(--color-text-muted)]">
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p>
<div className="flex justify-center gap-3">
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors"></button>
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white"></button>
</div>
</div>
);
@@ -88,59 +89,70 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
const nextPost = post.nextPost;
return (
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
<Link href="/" className="mb-8 inline-flex items-center gap-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
<ArrowLeft size={18} />
<span className="text-sm font-medium"></span>
</Link>
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
<main className="min-w-0 xl:flex-1">
<main className="min-w-0">
<article>
<header className="mb-10 border-b border-gray-100 pb-8">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
<header className="mb-10 border-b border-[var(--color-line)] pb-8">
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-2 rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
<Folder size={14} />
<span>{post.categoryName || 'Uncategorized'}</span>
<span>{post.categoryName || '미분류'}</span>
</div>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
<h1 className="mb-6 break-keep text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-6 text-sm text-[var(--color-text-muted)]">
<div className="flex items-center gap-2">
{profile?.imageUrl ? <img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> : <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"><User size={16} /></div>}
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
{profile?.imageUrl ? (
<Image
src={profile.imageUrl}
alt="작성자"
width={32}
height={32}
className="h-8 w-8 rounded-full border border-[var(--color-line)] object-cover shadow-sm"
unoptimized
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10"><User size={16} /></div>
)}
<span className="font-bold text-[var(--color-text)]">{profile?.name || 'Dev Park'}</span>
</div>
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString('ko-KR')}</div>
<div className="flex items-center gap-1.5"><Eye size={16} /> {post.viewCount.toLocaleString()}</div>
</div>
</header>
<div className="prose prose-lg max-w-none prose-headings:font-bold prose-a:text-blue-600 prose-img:rounded-2xl prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 mb-20">
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<MarkdownRenderer content={post.content || ''} />
</div>
</article>
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
{prevPost ? (
<Link href={`/posts/${prevPost.slug}`} className="group flex flex-col items-start gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"><ChevronLeft size={16} /> </span>
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span>
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> </span>
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
</Link>
) : <div className="hidden md:block p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"><ChevronLeft size={16} /> </span></div>}
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> </span></div>}
{nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"> <ChevronRight size={16} /></span>
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"> <ChevronRight size={16} /></span>
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
</Link>
) : <div className="hidden md:flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"> <ChevronRight size={16} /></span></div>}
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"> <ChevronRight size={16} /></span></div>}
</nav>
<CommentList postSlug={post.slug} />
</main>
<aside className="hidden 2xl:block w-[220px] shrink-0">
<aside className="hidden w-[220px] shrink-0 xl:block">
<div className="sticky top-24">
<TOC content={post.content || ''} />
</div>

View File

@@ -1,60 +1,64 @@
import Link from 'next/link';
import { Post } from '@/types';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { ChevronRight, Eye } from 'lucide-react';
import { clsx } from 'clsx';
import { Post } from '@/types';
import StatusBadge from '@/components/ui/StatusBadge';
interface PostListItemProps {
post: Post;
showViews?: boolean;
}
export default function PostListItem({ post }: PostListItemProps) {
// 📢 공지 카테고리 여부 확인
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
const isNoticePost = (post: Post) => {
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
};
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
export default function PostListItem({ post, showViews = false }: PostListItemProps) {
const isNotice = isNoticePost(post);
return (
<Link href={`/posts/${post.slug}`} className="block group">
<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={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={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"
)}>
<Link href={`/posts/${post.slug}`} className="group block">
<div
className={clsx(
'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-black/[0.03] dark:hover:bg-white/10',
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
{post.categoryName || '미분류'}
</StatusBadge>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
{post.title}
</h3>
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
{formatDate(post.createdAt)}
</time>
</div>
</div>
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
{/* 조회수 */}
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
<Eye size={14} />
<span className="text-xs">{post.viewCount}</span>
</div>
{/* 날짜 */}
<time className="font-light tabular-nums text-xs sm:text-sm">
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
</time>
<div className="ml-3 flex shrink-0 items-center gap-4 text-sm text-[var(--color-text-subtle)]">
{showViews && (
<div className="hidden items-center gap-1.5 sm:flex" title="조회수">
<Eye size={14} />
<span className="text-xs"> {post.viewCount.toLocaleString()}</span>
</div>
)}
<ChevronRight size={18} className="transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
</div>
</div>
</Link>
);
}
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { clsx } from 'clsx';
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
// npm install github-slugger 실행 필요
@@ -18,23 +18,21 @@ interface HeadingItem {
export default function TOC({ content }: TOCProps) {
const [activeId, setActiveId] = useState<string>('');
const [headings, setHeadings] = useState<HeadingItem[]>([]);
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
useEffect(() => {
const headings = useMemo(() => {
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
const lines = content.split('\n');
// 코드 블럭 내의 #은 무시
let inCodeBlock = false;
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
// 코드 블럭 진입/이탈 체크 (```)
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return acc;
return {
...acc,
inCodeBlock: !acc.inCodeBlock,
};
}
if (inCodeBlock) return acc;
if (acc.inCodeBlock) return acc;
// 헤딩 매칭 (# 1~3개)
const match = line.match(/^(#{1,3})\s+(.+)$/);
@@ -45,12 +43,15 @@ export default function TOC({ content }: TOCProps) {
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
const slug = slugger.slug(text);
acc.push({ text, level, slug });
return {
...acc,
headings: [...acc.headings, { text, level, slug }],
};
}
return acc;
}, []);
}, { headings: [], inCodeBlock: false });
setHeadings(matches);
return result.headings;
}, [content]);
// 2. 스크롤 감지 (IntersectionObserver)
@@ -103,18 +104,18 @@ export default function TOC({ content }: TOCProps) {
return (
<aside className="w-full">
<div className="border-l-2 border-gray-100 pl-4 py-2">
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4>
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]"></h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (
<li
key={`${heading.slug}-${index}`}
className={clsx(
"text-sm transition-all duration-200",
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기
"border-l-2 pl-3 text-sm transition-all duration-200",
heading.level === 3 ? "ml-3 text-xs" : "", // h3는 들여쓰기
activeId === heading.slug
? "text-blue-600 font-bold translate-x-1"
: "text-gray-500 hover:text-gray-900"
? "border-[var(--color-accent)] font-bold text-[var(--color-accent)]"
: "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
)}
>
<a
@@ -130,4 +131,4 @@ export default function TOC({ content }: TOCProps) {
</div>
</aside>
);
}
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx';
interface EmptyStateProps {
title: string;
description?: string;
icon?: ReactNode;
className?: string;
}
export default function EmptyState({
title,
description,
icon,
className,
}: EmptyStateProps) {
return (
<div
className={clsx(
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 px-5 py-10 text-center dark:bg-white/5',
className,
)}
>
{icon && <div className="mb-3 text-[var(--color-text-subtle)]">{icon}</div>}
<p className="text-sm font-semibold text-[var(--color-text-muted)]">{title}</p>
{description && (
<p className="mt-1 max-w-sm text-xs leading-5 text-[var(--color-text-subtle)]">
{description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx';
import Surface from './Surface';
interface MetricCardProps {
label: string;
value?: number | string | null;
icon?: ReactNode;
helper?: string;
changeRate?: number | null;
disabled?: boolean;
className?: string;
}
const formatValue = (value?: number | string | null) => {
if (value === null || value === undefined) return '통계 API 연결 전';
if (typeof value === 'number') return value.toLocaleString();
return value;
};
export default function MetricCard({
label,
value,
icon,
helper,
changeRate,
disabled = false,
className,
}: MetricCardProps) {
const hasChange = typeof changeRate === 'number';
const isPositive = hasChange && changeRate >= 0;
return (
<Surface className={clsx('p-5', disabled && 'opacity-70', className)}>
<div className="flex items-center justify-between gap-3 text-[var(--color-text-muted)]">
<span className="text-sm font-semibold">{label}</span>
{icon}
</div>
<p
className={clsx(
'mt-4 break-keep text-3xl font-bold tracking-normal text-[var(--color-text)]',
disabled && 'text-base leading-7 text-[var(--color-text-muted)]',
)}
>
{formatValue(value)}
</p>
{(helper || hasChange) && (
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
{hasChange ? `${isPositive ? '+' : ''}${changeRate.toFixed(1)}%` : helper}
</p>
)}
</Surface>
);
}

View File

@@ -0,0 +1,54 @@
import { clsx } from 'clsx';
export interface SegmentedControlOption<T extends string> {
label: string;
value: T;
}
interface SegmentedControlProps<T extends string> {
ariaLabel: string;
options: SegmentedControlOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export default function SegmentedControl<T extends string>({
ariaLabel,
options,
value,
onChange,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={clsx(
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-white/60 p-1 shadow-sm backdrop-blur-xl dark:bg-white/10',
className,
)}
role="group"
aria-label={ariaLabel}
>
{options.map((option) => {
const isSelected = option.value === value;
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
onClick={() => onChange(option.value)}
className={clsx(
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
isSelected
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { clsx } from 'clsx';
import { HTMLAttributes } from 'react';
type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone;
}
const toneClass: Record<BadgeTone, string> = {
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
info: 'border-blue-500/15 bg-blue-500/10 text-blue-700 dark:text-blue-300',
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',
};
export default function StatusBadge({
tone = 'neutral',
className,
...props
}: StatusBadgeProps) {
return (
<span
className={clsx(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold leading-none',
toneClass[tone],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,30 @@
import { clsx } from 'clsx';
import { HTMLAttributes } from 'react';
type SurfaceElement = 'div' | 'section' | 'article' | 'aside';
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
as?: SurfaceElement;
strong?: boolean;
interactive?: boolean;
}
export default function Surface({
as: Component = 'div',
strong = false,
interactive = false,
className,
...props
}: SurfaceProps) {
return (
<Component
className={clsx(
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
interactive && 'transition duration-150 hover:border-black/10 hover:bg-white/90 hover:shadow-[var(--shadow-panel)] dark:hover:border-white/15 dark:hover:bg-white/10',
className,
)}
{...props}
/>
);
}