Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 17 additions & 59 deletions app/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client';

import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { formatDate } from '@/app/lib/utils/format';
import PostListItem from '@/app/entities/admin/analytics/PostListItem';

interface PostItem {
postId: string;
Expand All @@ -28,67 +27,25 @@ function SkeletonList() {
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-700 overflow-hidden animate-pulse">
<div className="h-8 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800" />
<ul>
{[...Array(20)].map((_, i) => (
<li
key={i}
className="px-4 py-2.5 border-b border-gray-50 dark:border-gray-800 last:border-b-0 flex items-center gap-3"
>
<div className="h-3.5 w-4 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3.5 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3.5 w-14 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
</li>
))}
{[...Array(20)].map((_, i) => (
<li
key={i}
className="px-4 py-2.5 border-b border-gray-50 dark:border-gray-800 last:border-b-0 flex items-center gap-3"
>
<div className="h-3.5 w-4 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3.5 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
<div className="h-3.5 w-14 bg-gray-200 dark:bg-gray-700 rounded shrink-0" />
</li>
))}
</ul>
</div>
);
}

function PostListItem({
post,
rank,
viewsNode,
}: {
post: PostItem;
rank: number;
viewsNode: React.ReactNode;
}) {
return (
<li className="px-4 py-2.5 border-b border-gray-50 dark:border-gray-800 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 flex items-center gap-3 min-w-0">
<span className="text-xs text-gray-400 dark:text-gray-500 w-4 shrink-0 text-right">
{rank}
</span>
<Link
href={`/posts/${post.slug}`}
className="flex-1 text-sm font-medium truncate dark:text-gray-200 hover:text-brand-primary dark:hover:text-brand-secondary transition-colors min-w-0"
>
{post.title}
</Link>
<div className=" shrink-0 flex justify-center">
{post.seriesTitle ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-brand-primary/10 text-brand-primary dark:bg-brand-secondary/10 dark:text-brand-secondary whitespace-nowrap text-nowrap max-w-full">
{post.seriesTitle}
</span>
) : (
<span className="text-xs text-gray-300 dark:text-gray-600">—</span>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 shrink-0 text-center">
{formatDate(post.date)}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 w-12 shrink-0 text-center">
♥ {post.likeCount.toLocaleString()}
</span>
<span className="text-sm font-semibold shrink-0 w-20 text-right dark:text-gray-200">
{viewsNode}
</span>
</li>
);
}

function AnalyticsContent() {
const AnalyticsContent = () => {
const router = useRouter();
const searchParams = useSearchParams();
const tab = (searchParams.get('tab') ?? 'all') as TabKey;
Expand Down Expand Up @@ -168,6 +125,7 @@ function AnalyticsContent() {
<span className="w-24 shrink-0 text-center">작성일</span>
<span className="w-12 shrink-0 text-center">좋아요</span>
<span className="w-20 shrink-0 text-right">조회수</span>
<span className="w-4 shrink-0" />
</div>
<ul>
{posts.map((post, i) => (
Expand Down Expand Up @@ -196,7 +154,7 @@ function AnalyticsContent() {
)}
</div>
);
}
};

export default function StatsPage() {
return (
Expand Down
50 changes: 50 additions & 0 deletions app/api/admin/analytics/referrers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// GET /api/admin/analytics/referrers?postId=xxx
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';

function normalizeReferrer(ref: string): string {
if (!ref) return '직접 방문';
try {
const url = new URL(ref);
return url.hostname.replace(/^www\./, '');
} catch {
return ref;
}
}

export async function GET(request: NextRequest) {
try {
const session = await getServerSession();
if (!session) {
return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}

const postId = request.nextUrl.searchParams.get('postId');
if (!postId) {
return Response.json({ success: false, error: 'postId가 필요합니다.' }, { status: 400 });
}

await dbConnect();

const views = await View.find({ postId }, { referrer: 1 }).lean();

const counts: Record<string, number> = {};
for (const v of views) {
const key = normalizeReferrer((v as { referrer?: string }).referrer ?? '');
counts[key] = (counts[key] ?? 0) + 1;
}

const referrers = Object.entries(counts)
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count);

return Response.json({ success: true, referrers }, { status: 200 });
} catch (error) {
console.error('Error fetching referrers:', error);
return Response.json({ success: false, error: '유입 경로 통계 불러오기 실패' }, { status: 500 });
}
}
57 changes: 57 additions & 0 deletions app/api/admin/stats/daily/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// GET /api/admin/stats/daily - 최근 14일간 일별 조회수
import { getServerSession } from 'next-auth';
import dbConnect from '@/app/lib/dbConnect';
import View from '@/app/models/View';

export const dynamic = 'force-dynamic';

export async function GET() {
try {
const session = await getServerSession();
if (!session) {
return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}

await dbConnect();

const now = new Date();
const start = new Date(now);
start.setDate(start.getDate() - 13);
start.setHours(0, 0, 0, 0);

const views = await View.aggregate([
{ $match: { createdAt: { $gte: start } } },
{
$group: {
_id: {
$dateToString: { format: '%Y-%m-%d', date: '$createdAt', timezone: '+09:00' },
},
count: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
]);

// 14일 전체 날짜 배열 생성 (데이터 없는 날은 0)
const daily: { date: string; count: number }[] = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const dateStr = d.toLocaleDateString('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
// YYYY-MM-DD 형식으로 변환
const parts = d.toISOString().slice(0, 10);
const found = views.find((v) => v._id === parts);
daily.push({ date: parts, count: found ? found.count : 0 });
}

return Response.json({ success: true, daily }, { status: 200 });
} catch (error) {
console.error('Error fetching daily stats:', error);
return Response.json({ success: false, error: '일별 통계 불러오기 실패' }, { status: 500 });
}
}
6 changes: 5 additions & 1 deletion app/api/posts/view/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import dbConnect from '@/app/lib/dbConnect';
import View from '@/app/models/View';

export const POST = async (request: Request) => {
const { postId } = await request.json();
const { postId, referrer } = await request.json();
const fingerprint = request.headers.get('X-Fingerprint') || '';

if (!postId) {
Expand All @@ -17,6 +17,9 @@ export const POST = async (request: Request) => {

const existingLike = await View.findOne({ postId, fingerprint });
if (existingLike) {
if (!existingLike.referrer && referrer) {
await View.updateOne({ _id: existingLike._id }, { referrer });
}
const viewCount = await View.countDocuments({ postId });
return Response.json(
{ message: '이미 조회한 유저입니다.', viewCount },
Expand All @@ -26,6 +29,7 @@ export const POST = async (request: Request) => {
const result = await View.create({
postId,
fingerprint,
referrer: referrer || '',
});

const viewCount = await View.countDocuments({ postId });
Expand Down
144 changes: 144 additions & 0 deletions app/entities/admin/analytics/PostListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Link from 'next/link';
import { useState } from 'react';
import { FiChevronUp, FiChevronDown } from 'react-icons/fi';
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;
}

interface Referrer {
source: string;
count: number;
}

const PostListItem = ({
post,
rank,
viewsNode,
}: {
post: PostItem;
rank: number;
viewsNode: React.ReactNode;
}) => {
const [expanded, setExpanded] = useState(false);
const [referrers, setReferrers] = useState<Referrer[] | null>(null);
const [loadingRef, setLoadingRef] = useState(false);

const handleToggle = async () => {
if (!expanded && referrers === null) {
setLoadingRef(true);
try {
const res = await fetch(
`/api/admin/analytics/referrers?postId=${post.postId}`
);
const data = await res.json();
if (data.success) setReferrers(data.referrers);
} catch {
setReferrers([]);
} finally {
setLoadingRef(false);
}
}
setExpanded((prev) => !prev);
};

const total = referrers?.reduce((sum, r) => sum + r.count, 0) ?? 0;

return (
<li className="border-b border-gray-50 dark:border-gray-800 last:border-b-0">
{/* 메인 행 */}
<div className="px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 flex items-center gap-3 min-w-0">
<span className="text-xs text-gray-400 dark:text-gray-500 w-4 shrink-0 text-right">
{rank}
</span>
<Link
href={`/posts/${post.slug}`}
className="flex-1 text-sm font-medium truncate dark:text-gray-200 hover:text-brand-primary dark:hover:text-brand-secondary transition-colors min-w-0"
>
{post.title}
</Link>
<div className="shrink-0 flex justify-center">
{post.seriesTitle ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-brand-primary/10 text-brand-primary dark:bg-brand-secondary/10 dark:text-brand-secondary whitespace-nowrap text-nowrap max-w-full">
{post.seriesTitle}
</span>
) : (
<span className="text-xs text-gray-300 dark:text-gray-600">—</span>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 shrink-0 text-center">
{formatDate(post.date)}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 w-12 shrink-0 text-center">
♥ {post.likeCount.toLocaleString()}
</span>
<span className="text-sm font-semibold shrink-0 w-20 text-right dark:text-gray-200">
{viewsNode}
</span>
<button
onClick={handleToggle}
className="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-0.5"
aria-label="유입 경로 보기"
>
{expanded ? <FiChevronUp size={14} /> : <FiChevronDown size={14} />}
</button>
</div>

{/* 드롭다운: 유입 경로 */}
{expanded && (
<div className="px-6 pb-3 pt-1 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-100 dark:border-gray-700/50">
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
유입 경로
</p>
{loadingRef ? (
<div className="flex gap-2 animate-pulse">
{[80, 60, 100].map((w, i) => (
<div
key={i}
className={`h-3 bg-gray-200 dark:bg-gray-700 rounded`}
style={{ width: w }}
/>
))}
</div>
) : !referrers || referrers.length === 0 ? (
<p className="text-xs text-gray-400 dark:text-gray-500">
데이터 없음
</p>
) : (
<ul className="flex flex-col gap-1.5">
{referrers.map(({ source, count }) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<li key={source} className="flex items-center gap-3 min-w-0">
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0 truncate">
{source}
</span>
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-orange-400 rounded-full"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0 w-16 text-right">
{count.toLocaleString()}회 ({pct}%)
</span>
</li>
);
})}
</ul>
)}
</div>
)}
</li>
);
};

export default PostListItem;
Loading