From 20515bc148b8b402b103d69daaefdac1c21a4a9a Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Wed, 4 Mar 2026 09:55:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?style:=20=ED=86=B5=EA=B3=84=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=91=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/analytics/page.tsx | 215 ++++++++++++++++++++--- app/api/admin/analytics/popular/route.ts | 102 +++++++++++ 2 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 app/api/admin/analytics/popular/route.ts diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index 5e2f7fc..c3905f4 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -1,27 +1,202 @@ 'use client'; -import PopularPosts from '@/app/entities/post/list/PopularPosts'; -const StatsPage = () => ( -
-

블로그 통계

+import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; +import { formatDate } from '@/app/lib/utils/format'; -
-
-
- -
+interface PostItem { + postId: string; + title: string; + slug: string; + date: number; + seriesTitle?: string; + likeCount: number; + totalViews?: number; + todayViews: number; +} + +const TABS = [ + { key: 'all', label: '전체 인기 글 통계' }, + { key: 'today', label: '오늘 인기 글 통계' }, +] as const; + +type TabKey = (typeof TABS)[number]['key']; + +function SkeletonList() { + return ( +
    + {[...Array(20)].map((_, i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ); +} + +function PostListItem({ + post, + rank, + viewsNode, +}: { + post: PostItem; + rank: number; + viewsNode: React.ReactNode; +}) { + return ( +
  • + + {rank} + + + {post.title} + +
    + {post.seriesTitle ? ( + + {post.seriesTitle} + + ) : ( + + )}
    + + {formatDate(post.date)} + + + ♥ {post.likeCount.toLocaleString()} + + + {viewsNode} + +
  • + ); +} + +function AnalyticsContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const tab = (searchParams.get('tab') ?? 'all') as TabKey; + + const [allPosts, setAllPosts] = useState([]); + const [todayPosts, setTodayPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/admin/analytics/popular?type=${tab}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + if (tab === 'all') setAllPosts(data.posts); + else setTodayPosts(data.posts); + } catch { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; -
    -
    -

    통계 요약

    -

    - 가장 인기 있는 게시물의 통계 정보입니다. 조회수를 기준으로 - 정렬되었습니다. -

    -
    + fetchData(); + }, [tab]); + + const handleTabChange = (key: TabKey) => { + router.push(`/admin/analytics?tab=${key}`); + }; + + const posts = tab === 'all' ? allPosts : todayPosts; + const emptyMessage = + tab === 'all' ? '데이터가 없습니다.' : '오늘 조회된 게시글이 없습니다.'; + + return ( +
    +

    + 방문자 및 조회수 분석 +

    + + {/* 탭 */} +
    + {TABS.map(({ key, label }) => ( + + ))}
    + + {/* 리스트 */} + {loading ? ( + + ) : error ? ( +

    {error}

    + ) : posts.length === 0 ? ( +

    {emptyMessage}

    + ) : ( + <> + {/* 테이블 헤더 */} +
    + + 제목 + 시리즈 + 작성일 + 좋아요 + 조회수 +
    +
      + {posts.map((post, i) => ( + + {post.totalViews!.toLocaleString()} + {post.todayViews > 0 && ( + + (+{post.todayViews}) + + )} + + ) : ( + <>{post.todayViews.toLocaleString()}회 + ) + } + /> + ))} +
    + + )}
    -
    -); -export default StatsPage; + ); +} + +export default function StatsPage() { + return ( + + + + ); +} diff --git a/app/api/admin/analytics/popular/route.ts b/app/api/admin/analytics/popular/route.ts new file mode 100644 index 0000000..fa25932 --- /dev/null +++ b/app/api/admin/analytics/popular/route.ts @@ -0,0 +1,102 @@ +// GET /api/admin/analytics/popular?type=all|today +import { NextRequest } from 'next/server'; +import { getServerSession } from 'next-auth'; +import dbConnect from '@/app/lib/dbConnect'; +import View from '@/app/models/View'; + +export const dynamic = 'force-dynamic'; + +const commonLookups = [ + { + $lookup: { + from: 'posts', + localField: '_id', + foreignField: '_id', + as: 'post', + }, + }, + { $unwind: '$post' }, + { + $lookup: { + from: 'series', + localField: 'post.seriesId', + foreignField: '_id', + as: 'series', + }, + }, + { + $lookup: { + from: 'likes', + localField: '_id', + foreignField: 'postId', + as: 'likes', + }, + }, +]; + +const commonProject = { + _id: 0, + postId: '$_id', + title: '$post.title', + slug: '$post.slug', + date: '$post.date', + seriesTitle: { $arrayElemAt: ['$series.title', 0] }, + likeCount: { $size: '$likes' }, +}; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(); + if (!session) { + return Response.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const type = request.nextUrl.searchParams.get('type') ?? 'all'; + + await dbConnect(); + + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + if (type === 'today') { + const posts = await View.aggregate([ + { $match: { createdAt: { $gte: todayStart } } }, + { $group: { _id: '$postId', todayViews: { $sum: 1 } } }, + { $sort: { todayViews: -1 } }, + { $limit: 20 }, + ...commonLookups, + { $project: { ...commonProject, todayViews: 1 } }, + ]); + + return Response.json({ success: true, posts }, { status: 200 }); + } + + // type=all + const posts = await View.aggregate([ + { + $group: { + _id: '$postId', + totalViews: { $sum: 1 }, + todayViews: { + $sum: { $cond: [{ $gte: ['$createdAt', todayStart] }, 1, 0] }, + }, + }, + }, + { $sort: { totalViews: -1 } }, + { $limit: 20 }, + ...commonLookups, + { $project: { ...commonProject, totalViews: 1, todayViews: 1 } }, + ]); + + return Response.json({ success: true, posts }, { status: 200 }); + } catch (error) { + console.error('Error fetching popular posts:', error); + return Response.json( + { success: false, error: '인기 게시글 통계 불러오기 실패' }, + { status: 500 } + ); + } +} From 2963c8722544e499693d8092eaea3301d0e29116 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Wed, 4 Mar 2026 14:34:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=B6=95=EC=95=BD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/analytics/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index c3905f4..1889de0 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -63,9 +63,9 @@ function PostListItem({ > {post.title} -
    +
    {post.seriesTitle ? ( - + {post.seriesTitle} ) : ( @@ -152,7 +152,9 @@ function AnalyticsContent() { ) : error ? (

    {error}

    ) : posts.length === 0 ? ( -

    {emptyMessage}

    +

    + {emptyMessage} +

    ) : ( <> {/* 테이블 헤더 */}