diff --git a/app/admin/page.tsx b/app/admin/page.tsx index d4a54e8..4851b2f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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'; @@ -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('관리자 페이지에 오신 것을 환영합니다.'); } @@ -45,49 +47,49 @@ const AdminDashboard = () => { title: '블로그 포스트 작성', icon: , description: '새로운 글을 작성합니다.', - bgColor: 'bg-blue-950/20', // 짙은 파란색의 투명도 적용 + accent: 'border-l-brand-primary', link: '/admin/write', }, { title: '프로젝트 관리', icon: , description: '포트폴리오 프로젝트를 관리합니다.', - bgColor: 'bg-yellow-950/20', // 짙은 노란색의 투명도 적용 + accent: 'border-l-semantic-info', link: '/admin/portfolio', }, { title: '게시글 수정/삭제', icon: , description: '기존 게시글을 관리합니다.', - bgColor: 'bg-green-950/20', // 짙은 초록색의 투명도 적용 + accent: 'border-l-primary-bangladesh', link: '/admin/posts', }, { title: '방문자 및 조회수 분석', icon: , description: '블로그 통계를 확인합니다.', - bgColor: 'bg-purple-950/20', // 짙은 보라색의 투명도 적용 + accent: 'border-l-semantic-warning', link: '/admin/analytics', }, { title: '시리즈 관리', icon: , description: '블로그 시리즈를 관리합니다.', - bgColor: 'bg-emerald-950/20', // 짙은 보라색의 투명도 적용 + accent: 'border-l-brand-secondary', link: '/admin/series', }, { title: '댓글 확인 및 관리', icon: , description: '댓글을 관리합니다.', - bgColor: 'bg-pink-950/20', // 짙은 분홍색의 투명도 적용 + accent: 'border-l-semantic-error', link: '/admin/comments', }, { title: '블로그 설정 관리', icon: , description: '블로그 설정을 변경합니다.', - bgColor: 'bg-gray-800/20', // 짙은 회색의 투명도 적용 + accent: 'border-l-primary-mountain', link: '/admin/settings', }, ]; @@ -121,28 +123,48 @@ const AdminDashboard = () => { -
- {dashboardItems.map((item, index) => ( - -
-
- {item.icon} +
+ +
+ + {!mounted ? ( +
+ {[...Array(7)].map((_, i) => ( +
+
+
+
-

{item.title}

+
-

{item.description}

- - ))} -
+ ))} +
+ ) : ( +
+ {dashboardItems.map((item, index) => ( + +
+
+ {item.icon} +
+

{item.title}

+
+

{item.description}

+ + ))} +
+ )} -
+
-
); diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index ced8d67..d2ef520 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -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'; @@ -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( @@ -34,6 +40,8 @@ export async function GET() { totalSeries, publicPosts, privatePosts, + totalViews, + todayViews, }, }, { diff --git a/app/entities/admin/dashboard/QuickStats.tsx b/app/entities/admin/dashboard/QuickStats.tsx index 427089f..34d42bd 100644 --- a/app/entities/admin/dashboard/QuickStats.tsx +++ b/app/entities/admin/dashboard/QuickStats.tsx @@ -1,6 +1,32 @@ '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(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; @@ -8,6 +34,8 @@ interface Stats { publicPosts: number; privatePosts: number; activeSubscribers: number; + totalViews: number; + todayViews: number; } const QuickStats = () => { @@ -15,12 +43,14 @@ const QuickStats = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(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'), @@ -54,55 +84,74 @@ const QuickStats = () => { if (loading) { return ( -
-

빠른 통계

-
로딩 중...
+
+
+
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
); } if (error || !stats) { return ( -
-

빠른 통계

+
+

블로그 통계

{error || '통계를 불러올 수 없습니다.'}
); } + const secondaryStats = [ + { label: '전체 게시글', value: stats.totalPosts }, + { label: '전체 시리즈', value: stats.totalSeries }, + { label: '공개 게시글', value: stats.publicPosts }, + { label: '비공개 게시글', value: stats.privatePosts }, + { label: '활성 구독자', value: stats.activeSubscribers }, + ]; + return ( -
-

빠른 통계

-
-
-

전체 게시글

-

{stats.totalPosts}

-
-
-

전체 시리즈

-

- {stats.totalSeries} -

-
-
-

공개 게시글

-

- {stats.publicPosts} -

-
-
-

비공개 게시글

-

- {stats.privatePosts} +

+

블로그 통계

+ + {/* 조회수 강조 섹션 */} +
+
+

전체 조회수

+

+ {totalViewsCount.toLocaleString()}

-
-

활성 구독자

-

- {stats.activeSubscribers} +

+

오늘 조회수

+

+ {todayViewsCount.toLocaleString()}

+ + {/* 기타 통계 */} +
+ {secondaryStats.map(({ label, value }) => ( +
+

{label}

+

{value.toLocaleString()}

+
+ ))} +
); }; diff --git a/app/entities/admin/dashboard/RecentActivity.tsx b/app/entities/admin/dashboard/RecentActivity.tsx index f800fa5..add70df 100644 --- a/app/entities/admin/dashboard/RecentActivity.tsx +++ b/app/entities/admin/dashboard/RecentActivity.tsx @@ -42,41 +42,51 @@ const RecentActivity = () => { if (loading) { return ( -
-

최근 활동

-
로딩 중...
+
+
+
    + {[...Array(5)].map((_, i) => ( +
  • +
    +
    +
  • + ))} +
); } if (error) { return ( -
-

최근 활동

+
+

최근 활동

{error}
); } return ( -
-

최근 활동

+
+

최근 활동

{posts.length === 0 ? ( -

최근 게시글이 없습니다.

+

최근 게시글이 없습니다.

) : ( -
    +
      {posts.map((post) => (
    • {post.title} - + {formatDate(post.date)}