.
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

@@ -1,22 +1,37 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
Eye,
CalendarClock,
FileText,
FolderTree,
Loader2,
MessageSquareText,
PenLine,
Settings,
Sparkles,
} from 'lucide-react';
import { getAdminDashboard } from '@/api/dashboard';
import { getCategories } from '@/api/category';
import { getAdminComments } from '@/api/comments';
import { getPosts } from '@/api/posts';
import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types';
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) => {
@@ -31,10 +46,10 @@ const emptyPageMeta: PageMeta = {
last: true,
};
const formatDate = (value?: string, withTime = false) => {
const formatDate = (value?: string | Date, withTime = false) => {
if (!value) return '-';
const date = new Date(value);
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('ko-KR', {
@@ -56,40 +71,51 @@ const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
};
};
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 || '익명';
};
function PostRow({ post }: { post: Post }) {
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="flex items-center justify-between gap-4 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
className="group flex items-center 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">
<p className="truncate text-sm font-semibold text-gray-900">{post.title}</p>
<p className="mt-1 text-xs text-gray-500">
<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>
<div className="flex shrink-0 items-center gap-1 text-xs text-gray-400">
<Eye size={14} />
<span>{post.viewCount.toLocaleString()}</span>
</div>
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5" size={15} />
</Link>
);
}
function CommentRow({ comment }: { comment: AdminComment }) {
function RecentCommentRow({ comment }: { comment: AdminComment }) {
const content = (
<>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
<p className="mt-1 truncate text-xs text-gray-400">
<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-gray-300" size={15} />
<ArrowRight className="shrink-0 text-[var(--color-text-subtle)]" size={15} />
</>
);
@@ -97,7 +123,7 @@ function CommentRow({ comment }: { comment: AdminComment }) {
return (
<Link
href={`/posts/${comment.postSlug}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
className="flex items-center gap-3 rounded-lg px-3 py-3 transition hover:bg-black/[0.03] dark:hover:bg-white/10"
>
{content}
</Link>
@@ -107,7 +133,86 @@ function CommentRow({ comment }: { comment: AdminComment }) {
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' }),
@@ -132,42 +237,52 @@ export default function AdminPage() {
retry: 0,
});
const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading;
const latestPosts = latestData?.content ?? [];
const popularPosts = popularData?.content ?? [];
const recentComments = commentsData?.content ?? [];
const totalPosts = latestData?.page?.totalElements ?? latestData?.totalElements ?? 0;
const totalCategories = countCategories(categories);
const totalComments = getCommentListMeta(commentsData).totalElements;
const lastUpdatedAt = latestPosts[0]?.createdAt;
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-gray-200 pb-8 md:flex-row md:items-end">
<header className="flex flex-col justify-between gap-5 border-b border-[var(--color-line)] pb-8 md:flex-row md:items-end">
<div>
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
<StatusBadge tone="info" className="mb-3">
<Settings size={14} />
Admin
</p>
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
</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-gray-500">
. , , , .
<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-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
>
<FileText size={17} />
</Link>
<Link
href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-text)] px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90 dark:bg-white dark:text-black"
>
<PenLine size={17} />
@@ -175,126 +290,46 @@ export default function AdminPage() {
</div>
</header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between text-gray-500">
<span className="text-sm font-medium"> </span>
<FileText size={18} />
</div>
<p className="mt-4 text-3xl font-bold text-gray-950">{totalPosts.toLocaleString()}</p>
</div>
<AdminDashboardOverview
overview={dashboard?.overview}
fallbackTotals={{
totalPosts,
totalComments,
totalCategories,
lastPublishedAt: latestPosts[0]?.createdAt,
}}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between text-gray-500">
<span className="text-sm font-medium"></span>
<FolderTree size={18} />
</div>
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
</div>
<AdminDashboardTrafficChart
points={dashboard?.traffic ?? []}
range={range}
onRangeChange={setRange}
isFallback={isFallback}
/>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between text-gray-500">
<span className="text-sm font-medium"> </span>
<MessageSquareText size={18} />
</div>
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p>
</div>
<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>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between text-gray-500">
<span className="text-sm font-medium"> </span>
<Sparkles size={18} />
</div>
<p className="mt-4 text-2xl font-bold text-gray-950">{formatDate(lastUpdatedAt)}</p>
</div>
</section>
<RecentActivity
posts={recentPosts}
comments={recentComments}
isLoading={isFallbackLoading}
/>
<section className="grid gap-6 xl:grid-cols-3">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-950"> </h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
<Link
href="/admin/posts"
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
>
<ArrowRight size={15} />
</Link>
</div>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : latestPosts.length > 0 ? (
<div className="divide-y divide-gray-100">
{latestPosts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
) : (
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="text-lg font-bold text-gray-950"> </h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
{isLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : popularPosts.length > 0 ? (
<div className="mt-4 divide-y divide-gray-100">
{popularPosts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
) : (
<div className="mt-4 rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-950"> 5</h2>
<p className="mt-1 text-sm text-gray-500"> .</p>
</div>
<Link
href="/admin/comments"
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
>
<ArrowRight size={15} />
</Link>
</div>
{isCommentsLoading ? (
<div className="flex min-h-48 items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={28} />
</div>
) : recentComments.length > 0 ? (
<div className="divide-y divide-gray-100">
{recentComments.map((comment) => (
<CommentRow key={comment.id} comment={comment} />
))}
</div>
) : (
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
.
</div>
)}
</div>
</section>
<AdminDashboardCategoryHealth
categoryStats={dashboard?.categoryStats ?? []}
isFallback={isFallback}
/>
</div>
);
}