New components, convert hooks to components, bug fixes.

This commit is contained in:
Mike Cao 2020-08-03 23:20:35 -07:00
parent a2db27894f
commit 9d8a2406e1
21 changed files with 330 additions and 181 deletions

1
assets/arrow-right.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M216.464 36.465l-7.071 7.07c-4.686 4.686-4.686 12.284 0 16.971L387.887 239H12c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h375.887L209.393 451.494c-4.686 4.686-4.686 12.284 0 16.971l7.071 7.07c4.686 4.686 12.284 4.686 16.97 0l211.051-211.05c4.686-4.686 4.686-12.284 0-16.971L233.434 36.465c-4.686-4.687-12.284-4.687-16.97 0z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

13
components/Button.js Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import classNames from 'classnames';
import Icon from './Icon';
import styles from './Button.module.css';
export default function Button({ icon, children, className, onClick }) {
return (
<button type="button" className={classNames(styles.button, className)} onClick={onClick}>
{icon && <Icon icon={icon} />}
{children}
</button>
);
}

View File

@ -0,0 +1,22 @@
.button {
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
border: 0;
outline: none;
cursor: pointer;
}
.button:hover {
background: #eaeaea;
}
.button svg {
display: block;
width: 16px;
height: 16px;
margin-right: 8px;
}

View File

@ -1,13 +1,16 @@
import React, { useState, useRef, useEffect } from 'react';
function isInViewport(node) {
return (
window.pageYOffset < node.offsetTop + node.clientHeight ||
window.pageXOffset < node.offsetLeft + node.clientWidth
function isInViewport(element) {
const rect = element.getBoundingClientRect();
return !(
rect.bottom < 0 ||
rect.right < 0 ||
rect.left > window.innerWidth ||
rect.top > window.innerHeight
);
}
export default function CheckVisible({ children }) {
export default function CheckVisible({ className, children }) {
const [visible, setVisible] = useState(false);
const ref = useRef();
@ -30,5 +33,9 @@ export default function CheckVisible({ children }) {
};
}, [visible]);
return <div ref={ref}>{typeof children === 'function' ? children(visible) : children}</div>;
return (
<div ref={ref} className={className} data-visible={visible}>
{typeof children === 'function' ? children(visible) : children}
</div>
);
}

View File

@ -23,10 +23,10 @@ export default function DropDown({ value, options = [], onChange, className }) {
}
}
document.body.addEventListener('click', hideMenu);
document.addEventListener('click', hideMenu);
return () => {
document.body.removeEventListener('click', hideMenu);
document.removeEventListener('click', hideMenu);
};
}, [ref]);

7
components/Icon.js Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Icon.module.css';
export default function Icon({ icon, className }) {
return <div className={classNames(styles.icon, className)}>{icon}</div>;
}

View File

@ -0,0 +1,11 @@
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
vertical-align: middle;
}
.icon > svg {
width: 16px;
height: 16px;
}

12
components/Link.js Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
import classNames from 'classnames';
import NextLink from 'next/link';
import styles from './Link.module.css';
export default function Link({ href, className, children }) {
return (
<NextLink href={href}>
<a className={classNames(styles.link, className)}>{children}</a>
</NextLink>
);
}

View File

@ -0,0 +1,23 @@
.link,
.link:active,
.link:visited {
position: relative;
color: #2c2c2c;
text-decoration: none;
}
.link:before {
content: '';
position: absolute;
bottom: -2px;
width: 0;
height: 2px;
background: #2680eb;
opacity: 0.5;
transition: width 100ms;
}
.link:hover:before {
width: 100%;
transition: width 100ms;
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import Button from './Button';
import { getDateRange } from 'lib/date';
import styles from './QuickButtons.module.css';
@ -17,13 +18,13 @@ export default function QuickButtons({ value, onChange }) {
return (
<div className={styles.buttons}>
{Object.keys(options).map(key => (
<div
<Button
key={key}
className={classNames(styles.button, { [styles.active]: value === key })}
className={classNames({ [styles.active]: value === key })}
onClick={() => handleClick(key)}
>
{options[key]}
</div>
</Button>
))}
</div>
);

View File

@ -6,23 +6,16 @@
margin: auto;
}
.button {
font-size: 12px;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
margin-right: 10px;
cursor: pointer;
}
.button:last-child {
margin-right: 0;
}
.button:hover {
background: #eaeaea;
.buttons button + button {
margin-left: 10px;
}
.active {
font-weight: 600;
}
@media only screen and (max-width: 720px) {
.buttons button:last-child {
display: none;
}
}

View File

@ -57,9 +57,11 @@ export default function RankingsChart({
<div className={styles.title}>{title}</div>
<div className={styles.heading}>{heading}</div>
</div>
{rankings.map(({ x, y, z }) => (
<Row key={x} label={x} value={y} percent={z} animate={visible} />
))}
<div className={styles.body}>
{rankings.map(({ x, y, z }) => (
<Row key={x} label={x} value={y} percent={z} animate={visible} />
))}
</div>
</div>
)}
</CheckVisible>

View File

@ -73,3 +73,15 @@
background: #2680eb;
z-index: -1;
}
.body {
position: relative;
}
.body:empty:before {
content: 'No data available';
display: block;
color: #b3b3b3;
text-align: center;
line-height: 50px;
}

View File

@ -0,0 +1,49 @@
import React, { useState, useRef, useEffect } from 'react';
import classNames from 'classnames';
export default function StickyHeader({
className,
stickyClassName,
stickyStyle,
children,
enabled = true,
}) {
const [sticky, setSticky] = useState(false);
const ref = useRef();
const offsetTop = useRef(0);
useEffect(() => {
const checkPosition = () => {
if (ref.current) {
if (!offsetTop.current) {
offsetTop.current = ref.current.offsetTop;
}
const state = window.pageYOffset > offsetTop.current;
if (sticky !== state) {
setSticky(state);
}
}
};
checkPosition();
if (enabled) {
window.addEventListener('scroll', checkPosition);
}
return () => {
window.removeEventListener('scroll', checkPosition);
};
}, [sticky, enabled]);
return (
<div
ref={ref}
data-sticky={sticky}
className={classNames(className, { [stickyClassName]: sticky })}
{...(sticky && { style: stickyStyle })}
>
{children}
</div>
);
}

View File

@ -5,7 +5,7 @@ import CheckVisible from './CheckVisible';
import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons';
import DateFilter from './DateFilter';
import useSticky from './hooks/useSticky';
import StickyHeader from './StickyHeader';
import { get } from 'lib/web';
import { getDateArray, getDateRange, getTimezone } from 'lib/date';
import styles from './WebsiteChart.module.css';
@ -13,13 +13,13 @@ import styles from './WebsiteChart.module.css';
export default function WebsiteChart({
websiteId,
defaultDateRange = '7day',
stickHeader = false,
stickyHeader = false,
onDataLoad = () => {},
onDateChange = () => {},
}) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate, unit, value } = dateRange;
const [ref, sticky] = useSticky(stickHeader);
const container = useRef();
const [pageviews, uniques] = useMemo(() => {
@ -38,14 +38,15 @@ export default function WebsiteChart({
}
async function loadData() {
setData(
await get(`/api/website/${websiteId}/pageviews`, {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
}),
);
const data = await get(`/api/website/${websiteId}/pageviews`, {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
});
setData(data);
onDataLoad(data);
}
useEffect(() => {
@ -54,10 +55,11 @@ export default function WebsiteChart({
return (
<div ref={container}>
<div
ref={ref}
className={classNames(styles.header, 'row', { [styles.sticky]: sticky })}
style={{ width: sticky ? container.current.clientWidth : 'auto' }}
<StickyHeader
className={classNames(styles.header, 'row')}
stickyClassName={styles.sticky}
stickyStyle={{ width: container?.current?.clientWidth }}
enabled={stickyHeader}
>
<MetricsBar
className="col-12 col-md-9 col-lg-10"
@ -70,12 +72,11 @@ export default function WebsiteChart({
value={value}
onChange={handleDateChange}
/>
</div>
</StickyHeader>
<div className="row">
<CheckVisible>
<CheckVisible className="col">
{visible => (
<PageviewsChart
className="col"
websiteId={websiteId}
data={{ pageviews, uniques }}
unit={unit}

View File

@ -3,7 +3,6 @@ import classNames from 'classnames';
import WebsiteChart from './WebsiteChart';
import RankingsChart from './RankingsChart';
import WorldMap from './WorldMap';
import CheckVisible from './CheckVisible';
import { getDateRange } from 'lib/date';
import { get } from 'lib/web';
import { browserFilter, urlFilter, refFilter, deviceFilter, countryFilter } from 'lib/filters';
@ -14,6 +13,7 @@ const sessionClasses = 'col-12 col-lg-4';
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
const [data, setData] = useState();
const [chartLoaded, setChartLoaded] = useState(false);
const [countryData, setCountryData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate } = dateRange;
@ -22,6 +22,10 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
setData(await get(`/api/website/${websiteId}`));
}
function handleDataLoad() {
if (!chartLoaded) setTimeout(() => setChartLoaded(true), 300);
}
function handleDateChange(values) {
setTimeout(() => setDateRange(values), 300);
}
@ -41,84 +45,93 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
<div className="row">
<div className={classNames(styles.chart, 'col')}>
<h2>{data.label}</h2>
<WebsiteChart websiteId={websiteId} onDateChange={handleDateChange} stickHeader />
</div>
</div>
<div className={classNames(styles.row, 'row')}>
<div className={pageviewClasses}>
<RankingsChart
title="Pages"
type="url"
heading="Views"
<WebsiteChart
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={urlFilter}
/>
</div>
<div className={pageviewClasses}>
<RankingsChart
title="Referrers"
type="referrer"
heading="Views"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={refFilter}
/>
</div>
</div>
<div className={classNames(styles.row, 'row')}>
<div className={sessionClasses}>
<RankingsChart
title="Browsers"
type="browser"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={browserFilter}
/>
</div>
<div className={sessionClasses}>
<RankingsChart
title="Operating system"
type="os"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
</div>
<div className={sessionClasses}>
<RankingsChart
title="Devices"
type="screen"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={deviceFilter}
/>
</div>
</div>
<div className={classNames(styles.row, 'row')}>
<div className="col-12 col-md-12 col-lg-8">
<WorldMap data={countryData} />
</div>
<div className="col-12 col-md-12 col-lg-4">
<RankingsChart
title="Countries"
type="country"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={countryFilter}
onDataLoad={data => setCountryData(data)}
onDataLoad={handleDataLoad}
onDateChange={handleDateChange}
stickyHeader
/>
</div>
</div>
{chartLoaded && (
<>
<div className={classNames(styles.row, 'row')}>
<div className={pageviewClasses}>
<RankingsChart
title="Pages"
type="url"
heading="Views"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={urlFilter}
/>
</div>
<div className={pageviewClasses}>
<RankingsChart
title="Referrers"
type="referrer"
heading="Views"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={refFilter}
/>
</div>
</div>
<div className={classNames(styles.row, 'row')}>
<div className={sessionClasses}>
<RankingsChart
title="Browsers"
type="browser"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={browserFilter}
/>
</div>
<div className={sessionClasses}>
<RankingsChart
title="Operating system"
type="os"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
</div>
<div className={sessionClasses}>
<RankingsChart
title="Devices"
type="screen"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={deviceFilter}
/>
</div>
</div>
<div className={classNames(styles.row, 'row')}>
<div className="col-12 col-md-12 col-lg-8">
<WorldMap data={countryData} />
</div>
<div className="col-12 col-md-12 col-lg-4">
<RankingsChart
title="Countries"
type="country"
heading="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
dataFilter={countryFilter}
onDataLoad={data => setCountryData(data)}
/>
</div>
</div>
</>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@
.row {
border-top: 1px solid #e1e1e1;
min-height: 430px;
}
.row > [class*='col-'] {

View File

@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { get } from 'lib/web';
import Link from './Link';
import WebsiteChart from './WebsiteChart';
import Icon from './Icon';
import { get } from 'lib/web';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteList.module.css';
export default function WebsiteList() {
@ -16,18 +18,23 @@ export default function WebsiteList() {
}, []);
return (
<div className={styles.container}>
<>
{data &&
data.websites.map(({ website_id, label }) => (
<div key={website_id}>
<h2>
<Link href={`/website/${website_id}/${label}`}>
<a>{label}</a>
<div key={website_id} className={styles.website}>
<div className={styles.header}>
<h2>
<Link href={`/website/${website_id}/${label}`} className={styles.title}>
{label}
</Link>
</h2>
<Link href={`/website/${website_id}/${label}`} className={styles.details}>
<Icon icon={<Arrow />} /> View details
</Link>
</h2>
</div>
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
</div>
))}
</div>
</>
);
}

View File

@ -1,32 +1,29 @@
.container > div {
.website {
padding-bottom: 30px;
border-bottom: 1px solid #e1e1e1;
margin-bottom: 30px;
}
.container > div:last-child {
.website:last-child {
border-bottom: 0;
margin-bottom: 0;
}
.container a {
position: relative;
color: #2c2c2c;
text-decoration: none;
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.container a:before {
content: '';
position: absolute;
bottom: -2px;
width: 0;
height: 2px;
background: #2680eb;
opacity: 0.5;
transition: width 100ms;
.title {
color: #2c2c2c !important;
}
.container a:hover:before {
width: 100%;
transition: width 100ms;
.details {
font-size: 14px;
font-weight: 600;
}
.details svg {
margin-right: 5px;
}

View File

@ -1,31 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
export default function useSticky(enabled) {
const [node, setNode] = useState(null);
const [sticky, setSticky] = useState(false);
const offsetTop = useRef(0);
const ref = useCallback(node => {
offsetTop.current = node?.offsetTop;
setNode(node);
}, []);
useEffect(() => {
const checkPosition = () => {
const state = window.pageYOffset > offsetTop.current;
if (node && sticky !== state) {
setSticky(state);
}
};
if (enabled) {
window.addEventListener('scroll', checkPosition);
}
return () => {
window.removeEventListener('scroll', checkPosition);
};
}, [node, sticky, enabled]);
return [ref, sticky];
}

View File

@ -9,6 +9,7 @@ body {
width: 100%;
height: 100%;
box-sizing: border-box;
color: #2c2c2c;
background: #fafafa;
}
@ -25,6 +26,12 @@ body {
height: 100%;
}
button,
input,
select {
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
}
a,
a:active,
a:visited {
@ -32,7 +39,7 @@ a:visited {
}
header a {
color: #000 !important;
color: #2c2c2c !important;
text-decoration: none;
}
@ -52,6 +59,7 @@ select {
}
main {
flex: 1;
background: #fff;
}