Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { signIn, signOut, useSession } from 'next-auth/react';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { BiFolder , BiCommentDetail } from 'react-icons/bi';
import { FaChartBar } from 'react-icons/fa';
import { FaBuffer } from 'react-icons/fa6';
Expand All @@ -18,8 +18,10 @@ import DecryptedText from '../entities/bits/DecryptedText';
const AdminDashboard = () => {
const { data: session } = useSession();
const toast = useToast();
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
if (session) {
toast.success('관리자 페이지에 오신 것을 환영합니다.');
}
Expand All @@ -45,49 +47,49 @@ const AdminDashboard = () => {
title: '블로그 포스트 작성',
icon: <RiFileTextLine />,
description: '새로운 글을 작성합니다.',
bgColor: 'bg-blue-950/20', // 짙은 파란색의 투명도 적용
accent: 'border-l-brand-primary',
link: '/admin/write',
},
{
title: '프로젝트 관리',
icon: <BiFolder />,
description: '포트폴리오 프로젝트를 관리합니다.',
bgColor: 'bg-yellow-950/20', // 짙은 노란색의 투명도 적용
accent: 'border-l-semantic-info',
link: '/admin/portfolio',
},
{
title: '게시글 수정/삭제',
icon: <HiBookOpen />,
description: '기존 게시글을 관리합니다.',
bgColor: 'bg-green-950/20', // 짙은 초록색의 투명도 적용
accent: 'border-l-primary-bangladesh',
link: '/admin/posts',
},
{
title: '방문자 및 조회수 분석',
icon: <FaChartBar />,
description: '블로그 통계를 확인합니다.',
bgColor: 'bg-purple-950/20', // 짙은 보라색의 투명도 적용
accent: 'border-l-semantic-warning',
link: '/admin/analytics',
},
{
title: '시리즈 관리',
icon: <FaBuffer />,
description: '블로그 시리즈를 관리합니다.',
bgColor: 'bg-emerald-950/20', // 짙은 보라색의 투명도 적용
accent: 'border-l-brand-secondary',
link: '/admin/series',
},
{
title: '댓글 확인 및 관리',
icon: <BiCommentDetail />,
description: '댓글을 관리합니다.',
bgColor: 'bg-pink-950/20', // 짙은 분홍색의 투명도 적용
accent: 'border-l-semantic-error',
link: '/admin/comments',
},
{
title: '블로그 설정 관리',
icon: <IoSettingsSharp />,
description: '블로그 설정을 변경합니다.',
bgColor: 'bg-gray-800/20', // 짙은 회색의 투명도 적용
accent: 'border-l-primary-mountain',
link: '/admin/settings',
},
];
Expand Down Expand Up @@ -121,28 +123,48 @@ const AdminDashboard = () => {
</button>
</header>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 ">
{dashboardItems.map((item, index) => (
<Link
key={index}
href={item.link}
prefetch={false}
className={`${item.bgColor} p-6 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 hover:-translate-y-1`}
>
<div className="flex items-center mb-4">
<div className="p-2 border text-weak rounded-lg shadow-sm">
{item.icon}
<div className="mb-8">
<QuickStats />
</div>

{!mounted ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-pulse">
{[...Array(7)].map((_, i) => (
<div
key={i}
className="border border-gray-200 dark:border-gray-700 border-l-4 border-l-gray-300 dark:border-l-gray-600 rounded-lg p-6"
>
<div className="flex items-center mb-3">
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-5 w-36 bg-gray-200 dark:bg-gray-700 rounded ml-2" />
</div>
<h2 className="text-xl font-semibold ml-3">{item.title}</h2>
<div className="h-4 w-44 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<p className="text-default">{item.description}</p>
</Link>
))}
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{dashboardItems.map((item, index) => (
<Link
key={index}
href={item.link}
prefetch={false}
className={`border border-gray-200 dark:border-gray-700 border-l-4 ${item.accent} rounded-lg p-6 hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 hover:-translate-y-1`}
>
<div className="flex items-center mb-3">
<div className="p-2 text-gray-600 dark:text-gray-400 rounded-lg">
{item.icon}
</div>
<h2 className="text-lg font-semibold ml-2 dark:text-gray-100">{item.title}</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{item.description}</p>
</Link>
))}
</div>
)}

<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6 text-black">
<div className="mt-8">
<RecentActivity />
<QuickStats />
</div>
</div>
);
Expand Down
10 changes: 9 additions & 1 deletion app/api/admin/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import dbConnect from '@/app/lib/dbConnect';
import Post from '@/app/models/Post';
import Series from '@/app/models/Series';
import View from '@/app/models/View';

export const dynamic = 'force-dynamic';

Expand All @@ -18,12 +19,17 @@ export async function GET() {

await dbConnect();

const [totalPosts, totalSeries, publicPosts, privatePosts] =
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);

const [totalPosts, totalSeries, publicPosts, privatePosts, totalViews, todayViews] =
await Promise.all([
Post.countDocuments({}),
Series.countDocuments({}),
Post.countDocuments({ isPrivate: false }),
Post.countDocuments({ isPrivate: true }),
View.countDocuments({}),
View.countDocuments({ createdAt: { $gte: todayStart } }),
]);

return Response.json(
Expand All @@ -34,6 +40,8 @@ export async function GET() {
totalSeries,
publicPosts,
privatePosts,
totalViews,
todayViews,
},
},
{
Expand Down
117 changes: 83 additions & 34 deletions app/entities/admin/dashboard/QuickStats.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

function useCountUp(target: number, duration = 1200) {
const [count, setCount] = useState(0);
const rafRef = useRef<number | null>(null);

useEffect(() => {
if (target === 0) return;
const start = performance.now();

const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(eased * target));
if (progress < 1) rafRef.current = requestAnimationFrame(tick);
};

rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [target, duration]);

return count;
}

interface Stats {
totalPosts: number;
totalSeries: number;
publicPosts: number;
privatePosts: number;
activeSubscribers: number;
totalViews: number;
todayViews: number;
}

const QuickStats = () => {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const totalViewsCount = useCountUp(stats?.totalViews ?? 0);
const todayViewsCount = useCountUp(stats?.todayViews ?? 0);

useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);

// Fetch both stats in parallel
const [blogStatsRes, subscriberStatsRes] = await Promise.all([
fetch('/api/admin/stats'),
fetch('/api/admin/subscribers'),
Expand Down Expand Up @@ -54,55 +84,74 @@ const QuickStats = () => {

if (loading) {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">빠른 통계</h3>
<div className="text-gray-500">로딩 중...</div>
<div className="py-4 animate-pulse">
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded mb-6" />
<div className="grid grid-cols-2 gap-4 mb-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-3" />
<div className="h-12 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-8 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
</div>
);
}

if (error || !stats) {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">빠른 통계</h3>
<div className="py-4">
<h3 className="text-xl font-semibold mb-4 dark:text-white">블로그 통계</h3>
<div className="text-red-500">{error || '통계를 불러올 수 없습니다.'}</div>
</div>
);
}

const secondaryStats = [
{ label: '전체 게시글', value: stats.totalPosts },
{ label: '전체 시리즈', value: stats.totalSeries },
{ label: '공개 게시글', value: stats.publicPosts },
{ label: '비공개 게시글', value: stats.privatePosts },
{ label: '활성 구독자', value: stats.activeSubscribers },
];

return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">빠른 통계</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">전체 게시글</p>
<p className="text-2xl font-bold text-blue-600">{stats.totalPosts}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">전체 시리즈</p>
<p className="text-2xl font-bold text-green-600">
{stats.totalSeries}
</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">공개 게시글</p>
<p className="text-2xl font-bold text-purple-600">
{stats.publicPosts}
</p>
</div>
<div className="bg-orange-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">비공개 게시글</p>
<p className="text-2xl font-bold text-orange-600">
{stats.privatePosts}
<div className="py-4">
<h3 className="text-xl font-semibold mb-6 dark:text-white">블로그 통계</h3>

{/* 조회수 강조 섹션 */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">전체 조회수</p>
<p className="text-5xl font-bold tracking-tight dark:text-white">
{totalViewsCount.toLocaleString()}
</p>
</div>
<div className="bg-teal-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">활성 구독자</p>
<p className="text-2xl font-bold text-teal-600">
{stats.activeSubscribers}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">오늘 조회수</p>
<p className="text-5xl font-bold tracking-tight dark:text-white">
{todayViewsCount.toLocaleString()}
</p>
</div>
</div>

{/* 기타 통계 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{secondaryStats.map(({ label, value }) => (
<div key={label} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{label}</p>
<p className="text-2xl font-semibold dark:text-white">{value.toLocaleString()}</p>
</div>
))}
</div>
</div>
);
};
Expand Down
Loading