diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index 5e2f7fc..1889de0 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -1,27 +1,204 @@ '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 } + ); + } +}