Files
blog-frontend/src/app/admin/page.tsx
박원엽 b371200515
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
Polish macOS layer styling
2026-05-29 14:18:02 +09:00

336 lines
12 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
CalendarClock,
FileText,
Loader2,
PenLine,
Settings,
} from 'lucide-react';
import { getAdminDashboard } from '@/api/dashboard';
import { getCategories } from '@/api/category';
import { getAdminComments } from '@/api/comments';
import { getPosts } from '@/api/posts';
import AdminDashboardActionCenter from '@/components/admin/dashboard/AdminDashboardActionCenter';
import AdminDashboardCategoryHealth from '@/components/admin/dashboard/AdminDashboardCategoryHealth';
import AdminDashboardOverview from '@/components/admin/dashboard/AdminDashboardOverview';
import AdminDashboardPostPerformance from '@/components/admin/dashboard/AdminDashboardPostPerformance';
import AdminDashboardTrafficChart from '@/components/admin/dashboard/AdminDashboardTrafficChart';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
import {
AdminComment,
AdminCommentListResponse,
Category,
DashboardPostStat,
DashboardRange,
PageMeta,
Post,
} from '@/types';
const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => {
return count + 1 + countCategories(category.children || []);
}, 0);
};
const emptyPageMeta: PageMeta = {
totalPages: 0,
totalElements: 0,
number: 0,
last: true,
};
const formatDate = (value?: string | Date, withTime = false) => {
if (!value) return '-';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
}).format(date);
};
const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
if (!data) return emptyPageMeta;
return {
totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
number: data.page?.number ?? data.number ?? 0,
last: data.page?.last ?? data.last,
};
};
const getPostTotal = (data?: { page?: PageMeta; totalElements?: number }) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
};
const getAuthorName = (comment: AdminComment) => {
return comment.author || comment.memberNickname || comment.guestNickname || '익명';
};
const toPostStat = (post: Post): DashboardPostStat => ({
id: post.id,
title: post.title,
slug: post.slug,
categoryName: post.categoryName,
viewCount: post.viewCount,
rangeViewCount: post.viewCount,
createdAt: post.createdAt,
});
function RecentPostRow({ post }: { post: Post }) {
return (
<Link
href={`/posts/${post.slug}`}
className="group flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">{post.title}</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
</p>
</div>
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
</Link>
);
}
function RecentCommentRow({ comment }: { comment: AdminComment }) {
const content = (
<>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">{comment.content}</p>
<p className="mt-1 truncate text-xs text-[var(--color-text-subtle)]">
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
</p>
</div>
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)]" size={15} />
</>
);
if (comment.postSlug) {
return (
<Link
href={`/posts/${comment.postSlug}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-[var(--card-bg)]"
>
{content}
</Link>
);
}
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
}
function RecentActivity({
posts,
comments,
isLoading,
}: {
posts: Post[];
comments: AdminComment[];
isLoading: boolean;
}) {
return (
<section className="grid gap-6 xl:grid-cols-2">
<Surface className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> .</p>
</div>
<Link href="/admin/posts" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
<ArrowRight size={15} />
</Link>
</div>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
</div>
) : posts.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{posts.slice(0, 5).map((post) => (
<RecentPostRow key={post.id} post={post} />
))}
</div>
) : (
<EmptyState title="아직 작성된 글이 없습니다." />
)}
</Surface>
<Surface className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> .</p>
</div>
<Link href="/admin/comments" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
<ArrowRight size={15} />
</Link>
</div>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={28} />
</div>
) : comments.length > 0 ? (
<div className="divide-y divide-[var(--color-line)]">
{comments.slice(0, 5).map((comment) => (
<RecentCommentRow key={comment.id} comment={comment} />
))}
</div>
) : (
<EmptyState title="아직 작성된 댓글이 없습니다." />
)}
</Surface>
</section>
);
}
export default function AdminPage() {
const [range, setRange] = useState<DashboardRange>('30d');
const {
data: dashboard,
isLoading: isDashboardLoading,
} = useQuery({
queryKey: ['admin-dashboard', range],
queryFn: () => getAdminDashboard(range),
retry: 0,
});
const { data: latestData, isLoading: isLatestLoading } = useQuery({
queryKey: ['posts', 'admin', 'latest'],
queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }),
retry: 0,
});
const { data: popularData, isLoading: isPopularLoading } = useQuery({
queryKey: ['posts', 'admin', 'popular'],
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
retry: 0,
});
const { data: categories, isLoading: isCategoriesLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
retry: 0,
});
const { data: commentsData, isLoading: isCommentsLoading } = useQuery({
queryKey: ['comments', 'admin', 'recent'],
queryFn: () => getAdminComments(0, 5),
retry: 0,
});
const latestPosts = latestData?.content ?? [];
const recentComments = dashboard?.recentComments ?? commentsData?.content ?? [];
const recentPosts = dashboard?.recentPosts ?? latestPosts;
const totalPosts = dashboard?.overview.totalPosts ?? getPostTotal(latestData);
const totalCategories = dashboard?.overview.totalCategories ?? countCategories(categories);
const totalComments = dashboard?.overview.totalComments ?? getCommentListMeta(commentsData).totalElements;
const isFallback = !dashboard;
const isFallbackLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
const topPosts = useMemo(
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
[dashboard?.topPosts, popularData?.content],
);
return (
<div className="space-y-8">
<header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] pb-8 md:flex-row md:items-end">
<div>
<StatusBadge tone="info" className="mb-3">
<Settings size={14} />
</StatusBadge>
<h1 className="text-3xl font-bold tracking-normal text-[var(--color-text)] md:text-4xl">
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-text-muted)]">
. API가 .
</p>
<p className="mt-3 inline-flex items-center gap-2 text-xs font-medium text-[var(--color-text-subtle)]">
<CalendarClock size={14} />
{formatDate(new Date())}
{isDashboardLoading && ' · 통계 API 확인 중'}
{isFallback && !isDashboardLoading && ' · fallback 데이터 사용 중'}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/admin/posts"
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
<FileText size={17} />
</Link>
<Link
href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-accent)] px-5 py-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<PenLine size={17} />
</Link>
</div>
</header>
<AdminDashboardOverview
overview={dashboard?.overview}
fallbackTotals={{
totalPosts,
totalComments,
totalCategories,
lastPublishedAt: latestPosts[0]?.createdAt,
}}
/>
<AdminDashboardTrafficChart
points={dashboard?.traffic ?? []}
range={range}
onRangeChange={setRange}
isFallback={isFallback}
/>
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
<AdminDashboardPostPerformance
topPosts={topPosts}
risingPosts={dashboard?.risingPosts ?? []}
stalePopularPosts={dashboard?.stalePopularPosts ?? []}
isFallback={isFallback}
/>
<AdminDashboardActionCenter
actionItems={dashboard?.actionItems}
isFallback={isFallback}
/>
</div>
<RecentActivity
posts={recentPosts}
comments={recentComments}
isLoading={isFallbackLoading}
/>
<AdminDashboardCategoryHealth
categoryStats={dashboard?.categoryStats ?? []}
isFallback={isFallback}
/>
</div>
);
}