From cf574ae2c3c3f8a8a13823bde1d3674ca91ea7a2 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:48:59 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=B0=A8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/admin/stats/daily/route.ts | 57 ++++ app/entities/admin/dashboard/QuickStats.tsx | 60 +++- .../admin/dashboard/RecentViewChart.tsx | 88 +++++ package.json | 1 + pnpm-lock.yaml | 319 +++++++++++++++++- 5 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 app/api/admin/stats/daily/route.ts create mode 100644 app/entities/admin/dashboard/RecentViewChart.tsx diff --git a/app/api/admin/stats/daily/route.ts b/app/api/admin/stats/daily/route.ts new file mode 100644 index 0000000..fa3be98 --- /dev/null +++ b/app/api/admin/stats/daily/route.ts @@ -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 }); + } +} diff --git a/app/entities/admin/dashboard/QuickStats.tsx b/app/entities/admin/dashboard/QuickStats.tsx index 34d42bd..b5cdf4f 100644 --- a/app/entities/admin/dashboard/QuickStats.tsx +++ b/app/entities/admin/dashboard/QuickStats.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; +import DailyViewsChart from './RecentViewChart'; function useCountUp(target: number, duration = 1200) { const [count, setCount] = useState(0); @@ -38,8 +39,14 @@ interface Stats { todayViews: number; } +interface DailyView { + date: string; + count: number; +} + const QuickStats = () => { const [stats, setStats] = useState(null); + const [dailyViews, setDailyViews] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -51,9 +58,10 @@ const QuickStats = () => { try { setLoading(true); - const [blogStatsRes, subscriberStatsRes] = await Promise.all([ + const [blogStatsRes, subscriberStatsRes, dailyRes] = await Promise.all([ fetch('/api/admin/stats'), fetch('/api/admin/subscribers'), + fetch('/api/admin/stats/daily'), ]); if (!blogStatsRes.ok || !subscriberStatsRes.ok) { @@ -62,6 +70,7 @@ const QuickStats = () => { const blogData = await blogStatsRes.json(); const subscriberData = await subscriberStatsRes.json(); + const dailyData = dailyRes.ok ? await dailyRes.json() : null; if (blogData.success && subscriberData.success) { setStats({ @@ -71,6 +80,10 @@ const QuickStats = () => { } else { setError('통계를 불러올 수 없습니다.'); } + + if (dailyData?.success) { + setDailyViews(dailyData.daily); + } } catch (err) { setError('통계를 불러오는 중 오류가 발생했습니다.'); console.error(err); @@ -88,7 +101,10 @@ const QuickStats = () => {
{[...Array(2)].map((_, i) => ( -
+
@@ -96,7 +112,10 @@ const QuickStats = () => {
{[...Array(5)].map((_, i) => ( -
+
@@ -109,8 +128,12 @@ const QuickStats = () => { if (error || !stats) { return (
-

블로그 통계

-
{error || '통계를 불러올 수 없습니다.'}
+

+ 블로그 통계 +

+
+ {error || '통계를 불러올 수 없습니다.'} +
); } @@ -125,18 +148,24 @@ const QuickStats = () => { return (
-

블로그 통계

+

+ 블로그 통계 +

{/* 조회수 강조 섹션 */}
-

전체 조회수

+

+ 전체 조회수 +

{totalViewsCount.toLocaleString()}

-

오늘 조회수

+

+ 오늘 조회수 +

{todayViewsCount.toLocaleString()}

@@ -146,12 +175,21 @@ const QuickStats = () => { {/* 기타 통계 */}
{secondaryStats.map(({ label, value }) => ( -
-

{label}

-

{value.toLocaleString()}

+
+

+ {label} +

+

+ {value.toLocaleString()} +

))}
+ +
); }; diff --git a/app/entities/admin/dashboard/RecentViewChart.tsx b/app/entities/admin/dashboard/RecentViewChart.tsx new file mode 100644 index 0000000..3139000 --- /dev/null +++ b/app/entities/admin/dashboard/RecentViewChart.tsx @@ -0,0 +1,88 @@ +import { + CartesianGrid, + Dot, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +interface DailyView { + date: string; + count: number; +} + +const DailyViewsChart = ({ data }: { data: DailyView[] }) => { + if (data.length === 0) return null; + + const chartData = data.map((d) => ({ + date: `${new Date(d.date).getMonth() + 1}/${new Date(d.date).getDate()}`, + 조회수: d.count, + })); + + return ( +
+

+ 최근 14일 조회수 +

+ + + + + + [ + `${Number(value).toLocaleString()}회`, + '조회수', + ]} + cursor={{ stroke: '#fb923c', strokeWidth: 1, strokeOpacity: 0.4 }} + /> + } + activeDot={{ + r: 5, + fill: '#fb923c', + stroke: '#fff', + strokeWidth: 2, + }} + /> + + +
+ ); +}; + +export default DailyViewsChart; diff --git a/package.json b/package.json index 8f54aef..53faf4d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.5.0", "react-lottie-player": "^2.1.0", + "recharts": "^3.8.0", "resend": "^6.5.2", "sharp": "^0.33.5", "uuid": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483b12c..2491850 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-lottie-player: specifier: ^2.1.0 version: 2.1.0(react@18.3.1) + recharts: + specifier: ^3.8.0 + version: 3.8.0(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(redux@5.0.1) resend: specifier: ^6.5.2 version: 6.5.2 @@ -70,7 +73,7 @@ importers: version: 13.0.0 zustand: specifier: ^5.0.3 - version: 5.0.3(@types/react@18.3.20)(react@18.3.1) + version: 5.0.3(@types/react@18.3.20)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@next/bundle-analyzer': specifier: ^15.3.4 @@ -671,6 +674,17 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -689,6 +703,12 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -752,6 +772,33 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -832,6 +879,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -1281,6 +1331,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1363,6 +1417,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1402,6 +1500,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -1553,6 +1654,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -1706,6 +1810,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2020,6 +2127,12 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2051,6 +2164,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3133,6 +3250,18 @@ packages: '@types/react': '>=18' react: '>=18' + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3144,10 +3273,26 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3221,6 +3366,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@6.5.2: resolution: {integrity: sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==} engines: {node: '>=20'} @@ -3544,6 +3692,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.12: resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} engines: {node: '>=12.0.0'} @@ -3697,6 +3848,11 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3728,6 +3884,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -4511,6 +4670,18 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.20)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.20)(react@18.3.1)(redux@5.0.1) + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.11.0': {} @@ -4527,6 +4698,10 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -4603,6 +4778,30 @@ snapshots: dependencies: '@babel/types': 7.27.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -4689,6 +4888,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/webidl-conversions@7.0.3': {} '@types/whatwg-url@11.0.5': @@ -5214,6 +5415,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.2: {} @@ -5289,6 +5492,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-urls@3.0.2: @@ -5325,6 +5566,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.5.0: {} decode-named-character-reference@1.1.0: @@ -5566,6 +5809,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.45.1: {} + es6-promise@4.2.8: {} escalade@3.2.0: {} @@ -5792,6 +6037,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -6209,6 +6456,10 @@ snapshots: ignore@5.3.2: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6238,6 +6489,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7715,6 +7968,15 @@ snapshots: transitivePeerDependencies: - supports-color + react-redux@9.2.0(@types/react@18.3.20)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.20 + redux: 5.0.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -7727,11 +7989,37 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts@3.8.0(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.20)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.20)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7880,6 +8168,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.1.1: {} + resend@6.5.2: dependencies: svix: 1.76.1 @@ -8274,6 +8564,8 @@ snapshots: throttleit@2.1.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.12: dependencies: fdir: 6.4.3(picomatch@4.0.2) @@ -8474,6 +8766,10 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} uuid@10.0.0: {} @@ -8505,6 +8801,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 @@ -8654,9 +8967,11 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.3(@types/react@18.3.20)(react@18.3.1): + zustand@5.0.3(@types/react@18.3.20)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.20 + immer: 11.1.4 react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) zwitch@2.0.4: {} From 23b52ff181724b70b2bc84961eae23d044faa8ca Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:51:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20recharts=20outline=20=EC=95=88?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/globals.css b/app/globals.css index 3e6f633..9a7f187 100644 --- a/app/globals.css +++ b/app/globals.css @@ -362,3 +362,9 @@ article.post .post-body table thead tr:first-child th { article.post .post-body table tbody tr:last-child td { border-bottom: none; } + +/* rechart */ + +.recharts-wrapper * { + outline: none !important; +} From 15eda3cbc62cf196dbf2fd530887c4bb97fa2b55 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:12:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20referrer=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/analytics/page.tsx | 131 ++++++++++++++---- app/api/admin/analytics/referrers/route.ts | 50 +++++++ app/api/posts/view/route.ts | 6 +- .../post/detail/PostActionSection.tsx | 8 ++ app/models/View.ts | 4 + middleware.ts | 22 +++ 6 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 app/api/admin/analytics/referrers/route.ts create mode 100644 middleware.ts diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index f666b8d..f9ec24e 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; +import { FiChevronDown, FiChevronUp } from 'react-icons/fi'; import { formatDate } from '@/app/lib/utils/format'; interface PostItem { @@ -16,6 +17,11 @@ interface PostItem { todayViews: number; } +interface Referrer { + source: string; + count: number; +} + const TABS = [ { key: 'all', label: '전체 인기 글 통계' }, { key: 'today', label: '오늘 인기 글 통계' }, @@ -55,35 +61,105 @@ function PostListItem({ rank: number; viewsNode: React.ReactNode; }) { + const [expanded, setExpanded] = useState(false); + const [referrers, setReferrers] = useState(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 ( -
  • - - {rank} - - - {post.title} - -
    - {post.seriesTitle ? ( - - {post.seriesTitle} - - ) : ( - - )} +
  • + {/* 메인 행 */} +
    + + {rank} + + + {post.title} + +
    + {post.seriesTitle ? ( + + {post.seriesTitle} + + ) : ( + + )} +
    + + {formatDate(post.date)} + + + ♥ {post.likeCount.toLocaleString()} + + + {viewsNode} + +
    - - {formatDate(post.date)} - - - ♥ {post.likeCount.toLocaleString()} - - - {viewsNode} - + + {/* 드롭다운: 유입 경로 */} + {expanded && ( +
    +

    유입 경로

    + {loadingRef ? ( +
    + {[80, 60, 100].map((w, i) => ( +
    + ))} +
    + ) : !referrers || referrers.length === 0 ? ( +

    데이터 없음

    + ) : ( +
      + {referrers.map(({ source, count }) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
    • + + {source} + +
      +
      +
      + + {count.toLocaleString()}회 ({pct}%) + +
    • + ); + })} +
    + )} +
    + )}
  • ); } @@ -168,6 +244,7 @@ function AnalyticsContent() { 작성일 좋아요 조회수 +
      {posts.map((post, i) => ( diff --git a/app/api/admin/analytics/referrers/route.ts b/app/api/admin/analytics/referrers/route.ts new file mode 100644 index 0000000..c8e7f37 --- /dev/null +++ b/app/api/admin/analytics/referrers/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/app/api/posts/view/route.ts b/app/api/posts/view/route.ts index 053fbfd..fa02777 100644 --- a/app/api/posts/view/route.ts +++ b/app/api/posts/view/route.ts @@ -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) { @@ -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 }, @@ -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 }); diff --git a/app/entities/post/detail/PostActionSection.tsx b/app/entities/post/detail/PostActionSection.tsx index 2fded14..b44c730 100644 --- a/app/entities/post/detail/PostActionSection.tsx +++ b/app/entities/post/detail/PostActionSection.tsx @@ -41,10 +41,18 @@ const PostActionSection = ({ postId }: PostActionSectionProps) => { return; } + // 미들웨어가 저장한 쿠키 우선, 없으면 document.referrer 폴백 + const cookieReferrer = document.cookie + .split('; ') + .find((row) => row.startsWith('x-page-referrer=')) + ?.split('=')[1]; + const referrer = cookieReferrer ? decodeURIComponent(cookieReferrer) : document.referrer; + const response = await axios.post( '/api/posts/view', { postId, + referrer, }, { headers: { diff --git a/app/models/View.ts b/app/models/View.ts index b4f1482..6bd9f81 100644 --- a/app/models/View.ts +++ b/app/models/View.ts @@ -11,6 +11,10 @@ const viewSchema = new Schema( type: String, required: true, }, + referrer: { + type: String, + default: '', + }, timestamp: { type: Date, default: Date.now, diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..a0819e4 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export function middleware(request: NextRequest) { + // /posts/[slug] 페이지 진입 시 HTTP Referer 헤더를 쿠키로 저장 + if (request.nextUrl.pathname.startsWith('/posts/')) { + const referer = request.headers.get('referer') ?? ''; + const response = NextResponse.next(); + response.cookies.set('x-page-referrer', referer, { + maxAge: 60, // 1분만 유지 + path: '/', + sameSite: 'lax', + httpOnly: false, // 클라이언트 JS에서 읽어야 함 + }); + return response; + } + + return NextResponse.next(); +} + +export const config = { + matcher: '/posts/:path*', +}; From 373f07faad15b11da97449b5a4806c57482b6ce6 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:18:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/analytics/page.tsx | 151 ++---------------- app/entities/admin/analytics/PostListItem.tsx | 144 +++++++++++++++++ app/entities/post/list/PostListItem.tsx | 3 +- 3 files changed, 161 insertions(+), 137 deletions(-) create mode 100644 app/entities/admin/analytics/PostListItem.tsx diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index f9ec24e..da4b12b 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -1,10 +1,8 @@ 'use client'; -import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; -import { FiChevronDown, FiChevronUp } from 'react-icons/fi'; -import { formatDate } from '@/app/lib/utils/format'; +import PostListItem from '@/app/entities/admin/analytics/PostListItem'; interface PostItem { postId: string; @@ -17,11 +15,6 @@ interface PostItem { todayViews: number; } -interface Referrer { - source: string; - count: number; -} - const TABS = [ { key: 'all', label: '전체 인기 글 통계' }, { key: 'today', label: '오늘 인기 글 통계' }, @@ -34,137 +27,25 @@ function SkeletonList() {
        - {[...Array(20)].map((_, i) => ( -
      • -
        -
        -
        -
        -
        -
        -
      • - ))} + {[...Array(20)].map((_, i) => ( +
      • +
        +
        +
        +
        +
        +
        +
      • + ))}
      ); } -function PostListItem({ - post, - rank, - viewsNode, -}: { - post: PostItem; - rank: number; - viewsNode: React.ReactNode; -}) { - const [expanded, setExpanded] = useState(false); - const [referrers, setReferrers] = useState(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 ( -
    • - {/* 메인 행 */} -
      - - {rank} - - - {post.title} - -
      - {post.seriesTitle ? ( - - {post.seriesTitle} - - ) : ( - - )} -
      - - {formatDate(post.date)} - - - ♥ {post.likeCount.toLocaleString()} - - - {viewsNode} - - -
      - - {/* 드롭다운: 유입 경로 */} - {expanded && ( -
      -

      유입 경로

      - {loadingRef ? ( -
      - {[80, 60, 100].map((w, i) => ( -
      - ))} -
      - ) : !referrers || referrers.length === 0 ? ( -

      데이터 없음

      - ) : ( -
        - {referrers.map(({ source, count }) => { - const pct = total > 0 ? Math.round((count / total) * 100) : 0; - return ( -
      • - - {source} - -
        -
        -
        - - {count.toLocaleString()}회 ({pct}%) - -
      • - ); - })} -
      - )} -
      - )} -
    • - ); -} - -function AnalyticsContent() { +const AnalyticsContent = () => { const router = useRouter(); const searchParams = useSearchParams(); const tab = (searchParams.get('tab') ?? 'all') as TabKey; @@ -273,7 +154,7 @@ function AnalyticsContent() { )}
      ); -} +}; export default function StatsPage() { return ( diff --git a/app/entities/admin/analytics/PostListItem.tsx b/app/entities/admin/analytics/PostListItem.tsx new file mode 100644 index 0000000..e8d0996 --- /dev/null +++ b/app/entities/admin/analytics/PostListItem.tsx @@ -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(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 ( +
    • + {/* 메인 행 */} +
      + + {rank} + + + {post.title} + +
      + {post.seriesTitle ? ( + + {post.seriesTitle} + + ) : ( + + )} +
      + + {formatDate(post.date)} + + + ♥ {post.likeCount.toLocaleString()} + + + {viewsNode} + + +
      + + {/* 드롭다운: 유입 경로 */} + {expanded && ( +
      +

      + 유입 경로 +

      + {loadingRef ? ( +
      + {[80, 60, 100].map((w, i) => ( +
      + ))} +
      + ) : !referrers || referrers.length === 0 ? ( +

      + 데이터 없음 +

      + ) : ( +
        + {referrers.map(({ source, count }) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
      • + + {source} + +
        +
        +
        + + {count.toLocaleString()}회 ({pct}%) + +
      • + ); + })} +
      + )} +
      + )} +
    • + ); +}; + +export default PostListItem; diff --git a/app/entities/post/list/PostListItem.tsx b/app/entities/post/list/PostListItem.tsx index 94c6daa..b3e850c 100644 --- a/app/entities/post/list/PostListItem.tsx +++ b/app/entities/post/list/PostListItem.tsx @@ -1,7 +1,6 @@ import { FaTrash } from 'react-icons/fa'; import { FaPencil } from 'react-icons/fa6'; -import { Post } from '@/app/types/Post'; - +import type { Post } from '@/app/types/Post'; const PostListItem = (props: { post: Post; handleEdit: () => void;