@@ -21,64 +21,44 @@ function Skeleton({ className }: { className?: string }) {
2121export default function StatsPage ( ) {
2222 const [ stats , setStats ] = useState < PostStat [ ] > ( [ ] ) ;
2323 const [ loading , setLoading ] = useState ( true ) ;
24- const [ totalPosts , setTotalPosts ] = useState ( 0 ) ;
2524
2625 useEffect ( ( ) => {
2726 async function load ( ) {
2827 try {
29- // Fetch posts list from the existing posts API proxy
30- const postsRes = await fetch ( "/api/admin/posts" ) ;
31- // posts API might not be public — fall back to reactions/bulk approach
32- // We use the reactions bulk endpoint to discover slugs, and views/top for views
33- const [ viewsRes , reactionsTopRes ] = await Promise . all ( [
34- fetch ( "/api/views/top?limit=100" ) . then ( ( r ) => r . ok ? r . json ( ) : [ ] ) ,
35- fetch ( "/api/views/bulk?slugs=" ) . catch ( ( ) => ( { } ) ) ,
28+ // 1. 전체 포스트 목록 (slug, title, category)
29+ const postsRes = await fetch ( "/api/posts" ) ;
30+ const posts : { slug : string ; title : string ; category : string } [ ] =
31+ postsRes . ok ? await postsRes . json ( ) : [ ] ;
32+
33+ if ( posts . length === 0 ) { setLoading ( false ) ; return ; }
34+
35+ const slugs = posts . map ( ( p ) => p . slug ) . join ( "," ) ;
36+ const enc = encodeURIComponent ( slugs ) ;
37+
38+ // 2. 조회수 + 반응수 병렬 fetch
39+ const [ viewsData , reactionsData ] = await Promise . all ( [
40+ fetch ( `/api/views/bulk?slugs=${ enc } ` )
41+ . then ( ( r ) => r . ok ? r . json ( ) : { } )
42+ . catch ( ( ) => ( { } ) ) as Promise < Record < string , number > > ,
43+ fetch ( `/api/reactions/bulk?slugs=${ enc } ` )
44+ . then ( ( r ) => r . ok ? r . json ( ) : { } )
45+ . catch ( ( ) => ( { } ) ) as Promise < Record < string , Record < string , number > > > ,
3646 ] ) ;
3747
38- // viewsRes: [{slug, views}]
39- const viewMap : Record < string , number > = { } ;
40- let maxViews = 0 ;
41- for ( const { slug, views } of viewsRes as { slug : string ; views : number } [ ] ) {
42- viewMap [ slug ] = views ;
43- if ( views > maxViews ) maxViews = views ;
44- }
45-
46- // Get reaction counts for the slugs we know about
47- const knownSlugs = ( viewsRes as { slug : string } [ ] ) . map ( ( r ) => r . slug ) ;
48- let reactionMap : Record < string , number > = { } ;
49- if ( knownSlugs . length > 0 ) {
50- const reacRes = await fetch (
51- `/api/reactions/bulk?slugs=${ encodeURIComponent ( knownSlugs . join ( "," ) ) } `
52- ) ;
53- if ( reacRes . ok ) {
54- const reacData : Record < string , Record < string , number > > = await reacRes . json ( ) ;
55- for ( const [ slug , emojis ] of Object . entries ( reacData ) ) {
56- reactionMap [ slug ] = Object . values ( emojis ) . reduce ( ( a , b ) => a + b , 0 ) ;
57- }
58- }
59- }
60-
61- const combined : PostStat [ ] = ( viewsRes as { slug : string ; views : number } [ ] ) . map (
62- ( { slug, views } ) => {
63- // Extract category from slug (first segment)
64- const parts = slug . split ( "/" ) ;
65- const category = parts . length > 1 ? parts [ 0 ] : "" ;
66- // Use slug as title if we can't get the real title
67- const title = slug . split ( "/" ) . pop ( ) ?. replace ( / - / g, " " ) ?? slug ;
68- return {
69- slug,
70- title,
71- category,
72- views,
73- reactions : reactionMap [ slug ] ?? 0 ,
74- } ;
75- }
76- ) ;
48+ const combined : PostStat [ ] = posts . map ( ( p ) => ( {
49+ slug : p . slug ,
50+ title : p . title ,
51+ category : p . category ,
52+ views : viewsData [ p . slug ] ?? 0 ,
53+ reactions : Object . values ( reactionsData [ p . slug ] ?? { } ) . reduce (
54+ ( a : number , b : number ) => a + b ,
55+ 0
56+ ) ,
57+ } ) ) ;
7758
7859 setStats ( combined ) ;
79- setTotalPosts ( combined . length ) ;
8060 } catch {
81- // ignore errors
61+ /* ignore */
8262 } finally {
8363 setLoading ( false ) ;
8464 }
@@ -90,15 +70,18 @@ export default function StatsPage() {
9070 const totalReactions = stats . reduce ( ( s , p ) => s + p . reactions , 0 ) ;
9171
9272 const byViews = [ ...stats ] . sort ( ( a , b ) => b . views - a . views ) ;
93- const byReactions = [ ...stats ] . sort ( ( a , b ) => b . reactions - a . reactions ) . filter ( ( p ) => p . reactions > 0 ) ;
73+ const byReactions = [ ...stats ]
74+ . sort ( ( a , b ) => b . reactions - a . reactions )
75+ . filter ( ( p ) => p . reactions > 0 ) ;
9476
95- // Category breakdown
9677 const catViews : Record < string , number > = { } ;
9778 for ( const p of stats ) {
9879 const key = p . category || "기타" ;
9980 catViews [ key ] = ( catViews [ key ] ?? 0 ) + p . views ;
10081 }
101- const sortedCats = Object . entries ( catViews ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
82+ const sortedCats = Object . entries ( catViews )
83+ . filter ( ( [ , v ] ) => v > 0 )
84+ . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
10285 const maxCatViews = sortedCats [ 0 ] ?. [ 1 ] ?? 1 ;
10386
10487 return (
@@ -121,7 +104,7 @@ export default function StatsPage() {
121104 ) )
122105 ) : (
123106 [
124- { label : "조회된 글" , value : totalPosts , unit : "개" } ,
107+ { label : "전체 글" , value : stats . length , unit : "개" } ,
125108 { label : "누적 조회" , value : totalViews . toLocaleString ( ) , unit : "회" } ,
126109 { label : "누적 반응" , value : totalReactions . toLocaleString ( ) , unit : "개" } ,
127110 ] . map ( ( s ) => (
@@ -171,9 +154,9 @@ export default function StatsPage() {
171154 </ section >
172155 ) }
173156
174- { ( ! loading && sortedCats . length > 0 ) && < div className = "h-px bg-black/8 dark:bg-white/8" /> }
157+ { ! loading && < div className = "h-px bg-black/8 dark:bg-white/8" /> }
175158
176- { /* 조회수 TOP */ }
159+ { /* 조회수 TOP 10 */ }
177160 < section className = "space-y-3" >
178161 < h2 className = "text-xs font-semibold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest" >
179162 조회수 TOP 10
@@ -184,13 +167,13 @@ export default function StatsPage() {
184167 < Skeleton key = { i } className = "h-12 w-full rounded-xl" />
185168 ) ) }
186169 </ div >
187- ) : byViews . length === 0 ? (
170+ ) : byViews . filter ( ( p ) => p . views > 0 ) . length === 0 ? (
188171 < p className = "text-sm text-zinc-400 dark:text-zinc-500 text-center py-8" >
189172 아직 조회 데이터가 없어요
190173 </ p >
191174 ) : (
192175 < ol className = "space-y-2" >
193- { byViews . slice ( 0 , 10 ) . map ( ( p , i ) => (
176+ { byViews . filter ( ( p ) => p . views > 0 ) . slice ( 0 , 10 ) . map ( ( p , i ) => (
194177 < li key = { p . slug } >
195178 < Link
196179 href = { `/posts/${ p . slug } ` }
@@ -200,7 +183,7 @@ export default function StatsPage() {
200183 { i + 1 }
201184 </ span >
202185 < span className = "flex-1 text-sm font-medium text-zinc-800 dark:text-zinc-200 group-hover:text-violet-700 dark:group-hover:text-violet-300 transition-colors truncate" >
203- { p . slug }
186+ { p . title }
204187 </ span >
205188 < div className = "flex items-center gap-3 shrink-0 text-xs text-zinc-400 dark:text-zinc-500 tabular-nums" >
206189 < span className = "flex items-center gap-1" >
@@ -219,6 +202,7 @@ export default function StatsPage() {
219202 ) }
220203 </ section >
221204
205+ { /* 반응 TOP 10 */ }
222206 { ! loading && byReactions . length > 0 && (
223207 < >
224208 < div className = "h-px bg-black/8 dark:bg-white/8" />
@@ -237,7 +221,7 @@ export default function StatsPage() {
237221 { i + 1 }
238222 </ span >
239223 < span className = "flex-1 text-sm font-medium text-zinc-800 dark:text-zinc-200 group-hover:text-violet-700 dark:group-hover:text-violet-300 transition-colors truncate" >
240- { p . slug }
224+ { p . title }
241225 </ span >
242226 < span className = "shrink-0 text-xs tabular-nums text-zinc-400 dark:text-zinc-500" >
243227 { p . reactions } 개 반응
0 commit comments