|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { useEffect, useState } from 'react'; |
| 3 | +import { useEffect, useRef, useState } from 'react'; |
| 4 | + |
| 5 | +function useCountUp(target: number, duration = 1200) { |
| 6 | + const [count, setCount] = useState(0); |
| 7 | + const rafRef = useRef<number | null>(null); |
| 8 | + |
| 9 | + useEffect(() => { |
| 10 | + if (target === 0) return; |
| 11 | + const start = performance.now(); |
| 12 | + |
| 13 | + const tick = (now: number) => { |
| 14 | + const elapsed = now - start; |
| 15 | + const progress = Math.min(elapsed / duration, 1); |
| 16 | + // ease-out cubic |
| 17 | + const eased = 1 - Math.pow(1 - progress, 3); |
| 18 | + setCount(Math.round(eased * target)); |
| 19 | + if (progress < 1) rafRef.current = requestAnimationFrame(tick); |
| 20 | + }; |
| 21 | + |
| 22 | + rafRef.current = requestAnimationFrame(tick); |
| 23 | + return () => { |
| 24 | + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); |
| 25 | + }; |
| 26 | + }, [target, duration]); |
| 27 | + |
| 28 | + return count; |
| 29 | +} |
4 | 30 |
|
5 | 31 | interface Stats { |
6 | 32 | totalPosts: number; |
7 | 33 | totalSeries: number; |
8 | 34 | publicPosts: number; |
9 | 35 | privatePosts: number; |
10 | 36 | activeSubscribers: number; |
| 37 | + totalViews: number; |
| 38 | + todayViews: number; |
11 | 39 | } |
12 | 40 |
|
13 | 41 | const QuickStats = () => { |
14 | 42 | const [stats, setStats] = useState<Stats | null>(null); |
15 | 43 | const [loading, setLoading] = useState(true); |
16 | 44 | const [error, setError] = useState<string | null>(null); |
17 | 45 |
|
| 46 | + const totalViewsCount = useCountUp(stats?.totalViews ?? 0); |
| 47 | + const todayViewsCount = useCountUp(stats?.todayViews ?? 0); |
| 48 | + |
18 | 49 | useEffect(() => { |
19 | 50 | const fetchStats = async () => { |
20 | 51 | try { |
21 | 52 | setLoading(true); |
22 | 53 |
|
23 | | - // Fetch both stats in parallel |
24 | 54 | const [blogStatsRes, subscriberStatsRes] = await Promise.all([ |
25 | 55 | fetch('/api/admin/stats'), |
26 | 56 | fetch('/api/admin/subscribers'), |
@@ -54,55 +84,74 @@ const QuickStats = () => { |
54 | 84 |
|
55 | 85 | if (loading) { |
56 | 86 | return ( |
57 | | - <div className="bg-white p-6 rounded-lg shadow-md"> |
58 | | - <h3 className="text-xl font-semibold mb-4">빠른 통계</h3> |
59 | | - <div className="text-gray-500">로딩 중...</div> |
| 87 | + <div className="py-4 animate-pulse"> |
| 88 | + <div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded mb-6" /> |
| 89 | + <div className="grid grid-cols-2 gap-4 mb-6"> |
| 90 | + {[...Array(2)].map((_, i) => ( |
| 91 | + <div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-5"> |
| 92 | + <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-3" /> |
| 93 | + <div className="h-12 w-32 bg-gray-200 dark:bg-gray-700 rounded" /> |
| 94 | + </div> |
| 95 | + ))} |
| 96 | + </div> |
| 97 | + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4"> |
| 98 | + {[...Array(5)].map((_, i) => ( |
| 99 | + <div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> |
| 100 | + <div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded mb-2" /> |
| 101 | + <div className="h-8 w-10 bg-gray-200 dark:bg-gray-700 rounded" /> |
| 102 | + </div> |
| 103 | + ))} |
| 104 | + </div> |
60 | 105 | </div> |
61 | 106 | ); |
62 | 107 | } |
63 | 108 |
|
64 | 109 | if (error || !stats) { |
65 | 110 | return ( |
66 | | - <div className="bg-white p-6 rounded-lg shadow-md"> |
67 | | - <h3 className="text-xl font-semibold mb-4">빠른 통계</h3> |
| 111 | + <div className="py-4"> |
| 112 | + <h3 className="text-xl font-semibold mb-4 dark:text-white">블로그 통계</h3> |
68 | 113 | <div className="text-red-500">{error || '통계를 불러올 수 없습니다.'}</div> |
69 | 114 | </div> |
70 | 115 | ); |
71 | 116 | } |
72 | 117 |
|
| 118 | + const secondaryStats = [ |
| 119 | + { label: '전체 게시글', value: stats.totalPosts }, |
| 120 | + { label: '전체 시리즈', value: stats.totalSeries }, |
| 121 | + { label: '공개 게시글', value: stats.publicPosts }, |
| 122 | + { label: '비공개 게시글', value: stats.privatePosts }, |
| 123 | + { label: '활성 구독자', value: stats.activeSubscribers }, |
| 124 | + ]; |
| 125 | + |
73 | 126 | return ( |
74 | | - <div className="bg-white p-6 rounded-lg shadow-md"> |
75 | | - <h3 className="text-xl font-semibold mb-4">빠른 통계</h3> |
76 | | - <div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> |
77 | | - <div className="bg-blue-50 p-4 rounded-lg"> |
78 | | - <p className="text-sm text-gray-600 mb-1">전체 게시글</p> |
79 | | - <p className="text-2xl font-bold text-blue-600">{stats.totalPosts}</p> |
80 | | - </div> |
81 | | - <div className="bg-green-50 p-4 rounded-lg"> |
82 | | - <p className="text-sm text-gray-600 mb-1">전체 시리즈</p> |
83 | | - <p className="text-2xl font-bold text-green-600"> |
84 | | - {stats.totalSeries} |
85 | | - </p> |
86 | | - </div> |
87 | | - <div className="bg-purple-50 p-4 rounded-lg"> |
88 | | - <p className="text-sm text-gray-600 mb-1">공개 게시글</p> |
89 | | - <p className="text-2xl font-bold text-purple-600"> |
90 | | - {stats.publicPosts} |
91 | | - </p> |
92 | | - </div> |
93 | | - <div className="bg-orange-50 p-4 rounded-lg"> |
94 | | - <p className="text-sm text-gray-600 mb-1">비공개 게시글</p> |
95 | | - <p className="text-2xl font-bold text-orange-600"> |
96 | | - {stats.privatePosts} |
| 127 | + <div className="py-4"> |
| 128 | + <h3 className="text-xl font-semibold mb-6 dark:text-white">블로그 통계</h3> |
| 129 | + |
| 130 | + {/* 조회수 강조 섹션 */} |
| 131 | + <div className="grid grid-cols-2 gap-4 mb-6"> |
| 132 | + <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5"> |
| 133 | + <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">전체 조회수</p> |
| 134 | + <p className="text-5xl font-bold tracking-tight dark:text-white"> |
| 135 | + {totalViewsCount.toLocaleString()} |
97 | 136 | </p> |
98 | 137 | </div> |
99 | | - <div className="bg-teal-50 p-4 rounded-lg"> |
100 | | - <p className="text-sm text-gray-600 mb-1">활성 구독자</p> |
101 | | - <p className="text-2xl font-bold text-teal-600"> |
102 | | - {stats.activeSubscribers} |
| 138 | + <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5"> |
| 139 | + <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">오늘 조회수</p> |
| 140 | + <p className="text-5xl font-bold tracking-tight dark:text-white"> |
| 141 | + {todayViewsCount.toLocaleString()} |
103 | 142 | </p> |
104 | 143 | </div> |
105 | 144 | </div> |
| 145 | + |
| 146 | + {/* 기타 통계 */} |
| 147 | + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4"> |
| 148 | + {secondaryStats.map(({ label, value }) => ( |
| 149 | + <div key={label} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> |
| 150 | + <p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{label}</p> |
| 151 | + <p className="text-2xl font-semibold dark:text-white">{value.toLocaleString()}</p> |
| 152 | + </div> |
| 153 | + ))} |
| 154 | + </div> |
106 | 155 | </div> |
107 | 156 | ); |
108 | 157 | }; |
|
0 commit comments