.
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

@@ -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>
);
}