Skip to content

Commit 5dbe6f1

Browse files
committed
Implement post stats page
1 parent c64e1ef commit 5dbe6f1

File tree

9 files changed

+388
-100
lines changed

9 files changed

+388
-100
lines changed

src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ const ReadingListPage = loadable(
4646
},
4747
);
4848

49+
const PostStatsPage = loadable(() => import('./pages/PostStatsPage'));
50+
4951
interface AppProps {}
5052

51-
const App: React.FC<AppProps> = props => {
53+
const App: React.FC<AppProps> = (props) => {
5254
return (
5355
<JazzbarProvider>
5456
<Helmet>
@@ -81,6 +83,7 @@ const App: React.FC<AppProps> = props => {
8183
<Route path="/success" component={SuccessPage} />
8284
<Route path="/lists/:type(liked|read)" component={ReadingListPage} />
8385
<Route path="/lists" render={() => <Redirect to="/lists/liked" />} />
86+
<Route path="/post-stats/:postId" component={PostStatsPage} />
8487
<Route component={NotFoundPage} />
8588
</Switch>
8689
</ErrorBoundary>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import palette from '../../lib/styles/palette';
4+
5+
function SpinnerBlock() {
6+
return (
7+
<Block>
8+
<div className="sk-chase-dot"></div>
9+
<div className="sk-chase-dot"></div>
10+
<div className="sk-chase-dot"></div>
11+
<div className="sk-chase-dot"></div>
12+
<div className="sk-chase-dot"></div>
13+
<div className="sk-chase-dot"></div>
14+
</Block>
15+
);
16+
}
17+
18+
const Block = styled.div`
19+
width: 100px;
20+
height: 100px;
21+
position: relative;
22+
animation: sk-chase 2.5s infinite linear both;
23+
24+
.sk-chase-dot {
25+
width: 100%;
26+
height: 100%;
27+
position: absolute;
28+
left: 0;
29+
top: 0;
30+
animation: sk-chase-dot 2s infinite ease-in-out both;
31+
}
32+
33+
.sk-chase-dot:before {
34+
content: '';
35+
display: block;
36+
width: 25%;
37+
height: 25%;
38+
background-color: ${palette.teal6};
39+
border-radius: 100%;
40+
animation: sk-chase-dot-before 2s infinite ease-in-out both;
41+
}
42+
43+
.sk-chase-dot:nth-child(1) {
44+
animation-delay: -1.1s;
45+
}
46+
.sk-chase-dot:nth-child(2) {
47+
animation-delay: -1s;
48+
}
49+
.sk-chase-dot:nth-child(3) {
50+
animation-delay: -0.9s;
51+
}
52+
.sk-chase-dot:nth-child(4) {
53+
animation-delay: -0.8s;
54+
}
55+
.sk-chase-dot:nth-child(5) {
56+
animation-delay: -0.7s;
57+
}
58+
.sk-chase-dot:nth-child(6) {
59+
animation-delay: -0.6s;
60+
}
61+
.sk-chase-dot:nth-child(1):before {
62+
animation-delay: -1.1s;
63+
}
64+
.sk-chase-dot:nth-child(2):before {
65+
animation-delay: -1s;
66+
}
67+
.sk-chase-dot:nth-child(3):before {
68+
animation-delay: -0.9s;
69+
}
70+
.sk-chase-dot:nth-child(4):before {
71+
animation-delay: -0.8s;
72+
}
73+
.sk-chase-dot:nth-child(5):before {
74+
animation-delay: -0.7s;
75+
}
76+
.sk-chase-dot:nth-child(6):before {
77+
animation-delay: -0.6s;
78+
}
79+
80+
@keyframes sk-chase {
81+
100% {
82+
transform: rotate(360deg);
83+
}
84+
}
85+
86+
@keyframes sk-chase-dot {
87+
80%,
88+
100% {
89+
transform: rotate(360deg);
90+
}
91+
}
92+
93+
@keyframes sk-chase-dot-before {
94+
50% {
95+
transform: scale(0.4);
96+
}
97+
100%,
98+
0% {
99+
transform: scale(1);
100+
}
101+
}
102+
`;
103+
104+
export default SpinnerBlock;

src/components/post/PostHead.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export interface PostHeadProps {
147147
toc: React.ReactNode;
148148
isPrivate?: boolean;
149149
mobileLikeButton: React.ReactNode;
150+
onOpenStats(): void;
150151
}
151152

152153
const PostHead: React.FC<PostHeadProps> = ({
@@ -165,6 +166,7 @@ const PostHead: React.FC<PostHeadProps> = ({
165166
toc,
166167
isPrivate,
167168
mobileLikeButton,
169+
onOpenStats,
168170
}) => {
169171
const [askRemove, toggleAskRemove] = useToggle(false);
170172

@@ -178,6 +180,7 @@ const PostHead: React.FC<PostHeadProps> = ({
178180
<h1>{title}</h1>
179181
{ownPost && (
180182
<EditRemoveGroup>
183+
<button onClick={onOpenStats}>통계</button>
181184
<button onClick={onEdit}>수정</button>
182185
<button onClick={toggleAskRemove}>삭제</button>
183186
</EditRemoveGroup>
@@ -204,7 +207,7 @@ const PostHead: React.FC<PostHeadProps> = ({
204207
{series && (
205208
<PostSeriesInfo
206209
name={series.name}
207-
posts={series.series_posts.map(sp => sp.post)}
210+
posts={series.series_posts.map((sp) => sp.post)}
208211
postId={postId}
209212
username={username}
210213
urlSlug={series.url_slug}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import React, { useMemo, useEffect, useRef } from 'react';
2+
import styled from 'styled-components';
3+
import SpinnerBlock from '../common/SpinnerBlock';
4+
import { useQuery } from '@apollo/react-hooks';
5+
import { GET_STATS, Stats } from '../../lib/graphql/post';
6+
import { useParams } from 'react-router-dom';
7+
import palette from '../../lib/styles/palette';
8+
import format from 'date-fns/format';
9+
import { loadScript } from '../../lib/utils';
10+
11+
let promise = new Promise(() => {});
12+
13+
function loadChartJS() {
14+
return loadScript(
15+
'https://cdnjs.cloudflare.com/ajax/libs/echarts/5.1.1/echarts.min.js',
16+
);
17+
}
18+
19+
function PostStats() {
20+
const params = useParams<{ postId: string }>();
21+
const { data } = useQuery<{ getStats: Stats }>(GET_STATS, {
22+
variables: {
23+
post_id: params.postId,
24+
},
25+
});
26+
27+
const filledStats = useMemo(() => {
28+
if (!data) return null;
29+
const items = data.getStats.count_by_day;
30+
if (items.length < 2) return [];
31+
// make into map
32+
const countMap = new Map<string, number>();
33+
items.forEach((item) => {
34+
countMap.set(format(new Date(item.day), 'yyyy-MM-dd'), item.count);
35+
});
36+
37+
const lastDate = new Date(items[0].day);
38+
const current = new Date(items[items.length - 1].day);
39+
40+
const filled: { day: string; count: number }[] = [];
41+
while (current <= lastDate) {
42+
const formatted = format(current, 'yyyy-MM-dd');
43+
filled.push({
44+
day: formatted,
45+
count: countMap.get(formatted) ?? 0,
46+
});
47+
current.setDate(current.getDate() + 1);
48+
}
49+
50+
return filled;
51+
}, [data]);
52+
53+
// load chart js from cdn
54+
useEffect(() => {
55+
promise = loadChartJS();
56+
}, []);
57+
58+
const chartBoxRef = useRef<HTMLDivElement>(null);
59+
const chartInstance = useRef<any>(null);
60+
61+
useEffect(() => {
62+
if (!filledStats) return;
63+
64+
promise.then(() => {
65+
const { echarts } = window;
66+
if (!chartBoxRef.current) return;
67+
if (!echarts) return;
68+
let option = {
69+
tooltip: {
70+
trigger: 'axis',
71+
},
72+
73+
xAxis: {
74+
type: 'time',
75+
boundaryGap: false,
76+
},
77+
yAxis: {
78+
type: 'value',
79+
boundaryGap: [0, '25%'],
80+
},
81+
dataZoom:
82+
filledStats.length > 30
83+
? [
84+
{
85+
type: 'inside',
86+
start: filledStats.length - 30,
87+
end: filledStats.length,
88+
},
89+
{},
90+
]
91+
: undefined,
92+
series: [
93+
{
94+
name: '조회수',
95+
type: 'line',
96+
smooth: false,
97+
data: filledStats.map((item) => [item.day, item.count]),
98+
symbol: 'none',
99+
},
100+
],
101+
grid: {
102+
top: 32,
103+
left: 32,
104+
right: 8,
105+
},
106+
};
107+
108+
const myChart =
109+
chartInstance.current ?? echarts.init(chartBoxRef.current);
110+
chartInstance.current = myChart;
111+
myChart.setOption(option);
112+
});
113+
}, [filledStats]);
114+
115+
// handle chart responsive
116+
useEffect(() => {
117+
const handler = () => {
118+
if (!chartInstance.current) return;
119+
chartInstance.current.resize();
120+
};
121+
window.addEventListener('resize', handler);
122+
return () => {
123+
window.removeEventListener('resize', handler);
124+
};
125+
}, []);
126+
127+
if (!data)
128+
return (
129+
<LoaderWrapper>
130+
<SpinnerBlock />
131+
</LoaderWrapper>
132+
);
133+
134+
return (
135+
<Block>
136+
<Info>
137+
<Row>
138+
<span className="name">전체</span>
139+
<span className="value">{data.getStats.total.toLocaleString()}</span>
140+
</Row>
141+
<Row>
142+
<span className="name">오늘</span>
143+
<span className="value">
144+
{data.getStats.count_by_day[0]?.count ?? 0}
145+
</span>
146+
</Row>
147+
<Row>
148+
<span className="name">어제</span>
149+
<span className="value">
150+
{data.getStats.count_by_day[1]?.count ?? 0}
151+
</span>
152+
</Row>
153+
</Info>
154+
<ChartBox ref={chartBoxRef} />
155+
</Block>
156+
);
157+
}
158+
159+
const LoaderWrapper = styled.div`
160+
width: 100%;
161+
display: flex;
162+
height: 20rem;
163+
align-items: center;
164+
justify-content: center;
165+
`;
166+
167+
const Block = styled.div``;
168+
169+
const Info = styled.div`
170+
background: ${palette.gray0};
171+
padding: 1.5rem;
172+
border-radius: 0.5rem;
173+
`;
174+
const Row = styled.div`
175+
font-size: 1.5rem;
176+
177+
line-height: 1.5;
178+
.name {
179+
color: ${palette.gray9};
180+
font-weight: bold;
181+
}
182+
.value {
183+
color: ${palette.gray7};
184+
margin-left: 1rem;
185+
}
186+
187+
& + & {
188+
margin-top: 0.5rem;
189+
}
190+
`;
191+
192+
const ChartBox = styled.div`
193+
width: 100%;
194+
height: 32rem;
195+
margin-top: 2rem;
196+
`;
197+
198+
export default PostStats;

0 commit comments

Comments
 (0)