Merge pull request #85 from mikecao/dev

v0.20.0
This commit is contained in:
Mike Cao 2020-08-31 21:19:05 -07:00 committed by GitHub
commit bf6df3647a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 417 additions and 396 deletions

1
assets/redo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12z"/></svg>

After

Width:  |  Height:  |  Size: 653 B

1
assets/times.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"/></svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@ -1,13 +1,10 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import WebsiteChart from 'components/metrics/WebsiteChart';
import WorldMap from 'components/common/WorldMap';
import Page from 'components/layout/Page';
import WebsiteHeader from 'components/metrics/WebsiteHeader';
import MenuLayout from 'components/layout/MenuLayout';
import Button from 'components/common/Button';
import { getDateRange } from 'lib/date';
import { get } from 'lib/web';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteDetails.module.css';
import PagesTable from './metrics/PagesTable';
@ -18,15 +15,15 @@ import DevicesTable from './metrics/DevicesTable';
import CountriesTable from './metrics/CountriesTable';
import EventsTable from './metrics/EventsTable';
import EventsChart from './metrics/EventsChart';
import useFetch from 'hooks/useFetch';
import Loading from 'components/common/Loading';
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
const [data, setData] = useState();
export default function WebsiteDetails({ websiteId }) {
const { data } = useFetch(`/api/website/${websiteId}`);
const [chartLoaded, setChartLoaded] = useState(false);
const [countryData, setCountryData] = useState();
const [eventsData, setEventsData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const [expand, setExpand] = useState();
const { startDate, endDate, unit } = dateRange;
const BackButton = () => (
<Button
@ -48,23 +45,12 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
{ label: 'Browsers', value: 'browser', component: BrowsersTable },
{ label: 'Operating system', value: 'os', component: OSTable },
{ label: 'Devices', value: 'device', component: DevicesTable },
{
label: 'Countries',
value: 'country',
component: props => <CountriesTable {...props} onDataLoad={data => setCountryData(data)} />,
},
{ label: 'Countries', value: 'country', component: CountriesTable },
{ label: 'Events', value: 'event', component: EventsTable },
];
const dataProps = {
websiteId,
startDate,
endDate,
unit,
};
const tableProps = {
...dataProps,
websiteId,
websiteDomain: data?.domain,
limit: 10,
onExpand: handleExpand,
@ -76,16 +62,10 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
return menuOptions.find(e => e.value === value);
}
async function loadData() {
setData(await get(`/api/website/${websiteId}`));
}
function handleDataLoad() {
if (!chartLoaded) setTimeout(() => setChartLoaded(true), 300);
}
function handleDateChange(values) {
setTimeout(() => setDateRange(values), 300);
if (!chartLoaded) {
setTimeout(() => setChartLoaded(true), 300);
}
}
function handleExpand(value) {
@ -96,12 +76,6 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
setExpand(getSelectedMenuOption(value));
}
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId]);
if (!data) {
return null;
}
@ -110,15 +84,16 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
<Page>
<div className="row">
<div className={classNames(styles.chart, 'col')}>
<WebsiteHeader websiteId={websiteId} name={data.name} showLink={false} />
<WebsiteChart
websiteId={websiteId}
title={data.name}
onDataLoad={handleDataLoad}
onDateChange={handleDateChange}
showLink={false}
stickyHeader
/>
</div>
</div>
{!chartLoaded && <Loading />}
{chartLoaded && !expand && (
<>
<div className={classNames(styles.row, 'row')}>
@ -155,7 +130,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
<EventsTable {...tableProps} onDataLoad={setEventsData} />
</div>
<div className="col-12 col-md-12 col-lg-8 pt-5 pb-5">
<EventsChart {...dataProps} />
<EventsChart websiteId={websiteId} />
</div>
</div>
</>

View File

@ -1,25 +1,16 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useRouter } from 'next/router';
import WebsiteHeader from 'components/metrics/WebsiteHeader';
import WebsiteChart from 'components/metrics/WebsiteChart';
import Page from 'components/layout/Page';
import Button from 'components/common/Button';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg';
import { get } from 'lib/web';
import styles from './WebsiteList.module.css';
export default function WebsiteList() {
const [data, setData] = useState();
const router = useRouter();
async function loadData() {
setData(await get(`/api/websites`));
}
useEffect(() => {
loadData();
}, []);
const { data } = useFetch('/api/websites');
if (!data) {
return null;
@ -27,10 +18,9 @@ export default function WebsiteList() {
return (
<Page>
{data?.map(({ website_id, name }) => (
{data.map(({ website_id, name }) => (
<div key={website_id} className={styles.website}>
<WebsiteHeader websiteId={website_id} name={name} showLink />
<WebsiteChart key={website_id} title={name} websiteId={website_id} />
<WebsiteChart websiteId={website_id} title={name} showLink />
</div>
))}
{data.length === 0 && (

View File

@ -20,10 +20,12 @@ export default function DropDown({
setShowMenu(state => !state);
}
function handleSelect(value, e) {
function handleSelect(selected, e) {
e.stopPropagation();
setShowMenu(false);
onChange(value);
if (selected !== value) {
onChange(selected);
}
}
useDocumentClick(e => {

View File

@ -0,0 +1,19 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { setDateRange } from 'redux/actions/websites';
import Button from './Button';
import Refresh from 'assets/redo.svg';
import { useDateRange } from 'hooks/useDateRange';
export default function RefreshButton({ websiteId }) {
const dispatch = useDispatch();
const dateRange = useDateRange(websiteId);
function handleClick() {
if (dateRange) {
dispatch(setDateRange(websiteId, dateRange));
}
}
return <Button icon={<Refresh />} size="small" onClick={handleClick} />;
}

View File

@ -0,0 +1,26 @@
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import styles from './Toast.module.css';
import Icon from 'components/common/Icon';
import Close from 'assets/times.svg';
export default function Toast({ message, timeout = 3000, onClose }) {
const props = useSpring({
opacity: 1,
transform: 'translate3d(0,0px,0)',
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
});
useEffect(() => {
setTimeout(onClose, timeout);
}, []);
return ReactDOM.createPortal(
<animated.div className={styles.toast} style={props} onClick={onClose}>
<div className={styles.message}>{message}</div>
<Icon className={styles.close} icon={<Close />} size="small" />
</animated.div>,
document.getElementById('__modals'),
);
}

View File

@ -0,0 +1,25 @@
.toast {
position: absolute;
top: 30px;
left: 0;
right: 0;
width: 300px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
color: var(--gray50);
background: var(--green400);
margin: auto;
z-index: 2;
cursor: pointer;
}
.message {
font-size: var(--font-size-normal);
}
.close {
margin-left: 20px;
}

View File

@ -1,32 +1,20 @@
import React, { useState, useEffect } from 'react';
import React, { useMemo } from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import { get } from 'lib/web';
import useFetch from 'hooks/useFetch';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className }) {
const [count, setCount] = useState(0);
async function loadData() {
const result = await get(`/api/website/${websiteId}/active`);
setCount(result?.[0]?.x);
}
const { data } = useFetch(`/api/website/${websiteId}/active`, {}, { interval: 60000 });
const count = useMemo(() => {
return data?.[0]?.x || 0;
}, [data]);
const props = useSpring({
x: count,
from: { x: 0 },
});
useEffect(() => {
loadData();
const id = setInterval(() => loadData(), 60000);
return () => {
clearInterval(id);
};
}, []);
if (count === 0) {
return null;
}

View File

@ -4,6 +4,7 @@ import classNames from 'classnames';
import ChartJS from 'chart.js';
import styles from './BarChart.module.css';
import { format } from 'date-fns';
import { formatLongNumber } from '../../lib/format';
export default function BarChart({
chartId,
@ -21,7 +22,7 @@ export default function BarChart({
const chart = useRef();
const [tooltip, setTooltip] = useState({});
const renderLabel = (label, index, values) => {
const renderXLabel = (label, index, values) => {
const d = new Date(values[index].value);
const n = records;
@ -40,6 +41,10 @@ export default function BarChart({
}
};
const renderYLabel = label => {
return +label > 1 ? formatLongNumber(label) : label;
};
const renderTooltip = model => {
const { opacity, title, body, labelColors } = model;
@ -82,7 +87,7 @@ export default function BarChart({
tooltipFormat: 'ddd MMMM DD YYYY',
},
ticks: {
callback: renderLabel,
callback: renderXLabel,
minRotation: 0,
maxRotation: 0,
},
@ -96,6 +101,7 @@ export default function BarChart({
yAxes: [
{
ticks: {
callback: renderYLabel,
beginAtZero: true,
},
stacked,
@ -119,7 +125,7 @@ export default function BarChart({
const { options } = chart.current;
options.scales.xAxes[0].time.unit = unit;
options.scales.xAxes[0].ticks.callback = renderLabel;
options.scales.xAxes[0].ticks.callback = renderXLabel;
options.animation.duration = animationDuration;
onUpdate(chart.current);

View File

@ -2,15 +2,13 @@ import React from 'react';
import MetricsTable from './MetricsTable';
import { browserFilter } from 'lib/filters';
export default function BrowsersTable({ websiteId, startDate, endDate, limit, onExpand }) {
export default function BrowsersTable({ websiteId, limit, onExpand }) {
return (
<MetricsTable
title="Browsers"
type="browser"
metric="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={browserFilter}
onExpand={onExpand}

View File

@ -2,22 +2,13 @@ import React from 'react';
import MetricsTable from './MetricsTable';
import { countryFilter, percentFilter } from 'lib/filters';
export default function CountriesTable({
websiteId,
startDate,
endDate,
limit,
onDataLoad,
onExpand,
}) {
export default function CountriesTable({ websiteId, limit, onDataLoad = () => {}, onExpand }) {
return (
<MetricsTable
title="Countries"
type="country"
metric="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={countryFilter}
onDataLoad={data => onDataLoad(percentFilter(data))}

View File

@ -2,15 +2,13 @@ import React from 'react';
import MetricsTable from './MetricsTable';
import { deviceFilter } from 'lib/filters';
export default function DevicesTable({ websiteId, startDate, endDate, limit, onExpand }) {
export default function DevicesTable({ websiteId, limit, onExpand }) {
return (
<MetricsTable
title="Devices"
type="device"
metric="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={deviceFilter}
onExpand={onExpand}

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react';
import classNames from 'classnames';
import React, { useMemo } from 'react';
import tinycolor from 'tinycolor2';
import BarChart from './BarChart';
import { get } from 'lib/web';
import { getTimezone, getDateArray, getDateLength } from 'lib/date';
import styles from './BarChart.module.css';
import useFetch from 'hooks/useFetch';
import { useDateRange } from 'hooks/useDateRange';
const COLORS = [
'#2680eb',
@ -17,31 +16,21 @@ const COLORS = [
'#85d044',
];
export default function EventsChart({ websiteId, startDate, endDate, unit }) {
const [data, setData] = useState();
const datasets = useMemo(() => {
if (!data) return [];
return Object.keys(data).map((key, index) => {
const color = tinycolor(COLORS[index]);
return {
label: key,
data: data[key],
lineTension: 0,
backgroundColor: color.setAlpha(0.4).toRgbString(),
borderColor: color.setAlpha(0.5).toRgbString(),
borderWidth: 1,
};
});
}, [data]);
async function loadData() {
const data = await get(`/api/website/${websiteId}/events`, {
export default function EventsChart({ websiteId }) {
const dateRange = useDateRange(websiteId);
const { startDate, endDate, unit, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/events`,
{
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
});
},
{ update: [modified] },
);
const datasets = useMemo(() => {
if (!data) return [];
const map = data.reduce((obj, { x, t, y }) => {
if (!obj[x]) {
@ -57,8 +46,18 @@ export default function EventsChart({ websiteId, startDate, endDate, unit }) {
map[key] = getDateArray(map[key], startDate, endDate, unit);
});
setData(map);
}
return Object.keys(map).map((key, index) => {
const color = tinycolor(COLORS[index]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.setAlpha(0.4).toRgbString(),
borderColor: color.setAlpha(0.5).toRgbString(),
borderWidth: 1,
};
});
}, [data]);
function handleCreate(options) {
const legend = {
@ -74,10 +73,6 @@ export default function EventsChart({ websiteId, startDate, endDate, unit }) {
chart.update();
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate]);
if (!data) {
return null;
}

View File

@ -2,22 +2,13 @@ import React from 'react';
import MetricsTable from './MetricsTable';
import styles from './EventsTable.module.css';
export default function EventsTable({
websiteId,
startDate,
endDate,
limit,
onExpand,
onDataLoad,
}) {
export default function EventsTable({ websiteId, limit, onExpand, onDataLoad }) {
return (
<MetricsTable
title="Events"
type="event"
metric="Actions"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
renderLabel={({ x }) => <Label value={x} />}
onExpand={onExpand}

View File

@ -4,7 +4,7 @@ import { formatNumber } from '../../lib/format';
import styles from './MetricCard.module.css';
const MetricCard = ({ value = 0, label, format = formatNumber }) => {
const props = useSpring({ x: value, from: { x: 0 } });
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
return (
<div className={styles.card}>

View File

@ -1,48 +1,55 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import MetricCard from './MetricCard';
import { get } from 'lib/web';
import Loading from 'components/common/Loading';
import useFetch from 'hooks/useFetch';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import styles from './MetricsBar.module.css';
import { useDateRange } from '../../hooks/useDateRange';
export default function MetricsBar({ websiteId, startDate, endDate, className }) {
const [data, setData] = useState({});
export default function MetricsBar({ websiteId, className }) {
const dateRange = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/metrics`,
{
start_at: +startDate,
end_at: +endDate,
},
{
update: [modified],
},
);
const [format, setFormat] = useState(true);
const { pageviews, uniques, bounces, totaltime } = data;
const formatFunc = format ? formatLongNumber : formatNumber;
async function loadData() {
setData(
await get(`/api/website/${websiteId}/metrics`, {
start_at: +startDate,
end_at: +endDate,
}),
);
}
function handleSetFormat() {
setFormat(state => !state);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate]);
const { pageviews, uniques, bounces, totaltime } = data || {};
return (
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
<MetricCard label="Views" value={pageviews} format={formatFunc} />
<MetricCard label="Visitors" value={uniques} format={formatFunc} />
<MetricCard
label="Bounce rate"
value={pageviews ? (bounces / pageviews) * 100 : 0}
format={n => Number(n).toFixed(0) + '%'}
/>
<MetricCard
label="Average visit time"
value={totaltime && pageviews ? totaltime / (pageviews - bounces) : 0}
format={n => formatShortTime(n, ['m', 's'], ' ')}
/>
{!data ? (
<Loading />
) : (
<>
<MetricCard label="Views" value={pageviews} format={formatFunc} />
<MetricCard label="Visitors" value={uniques} format={formatFunc} />
<MetricCard
label="Bounce rate"
value={pageviews ? (bounces / pageviews) * 100 : 0}
format={n => Number(n).toFixed(0) + '%'}
/>
<MetricCard
label="Average visit time"
value={totaltime && pageviews ? totaltime / (pageviews - bounces) : 0}
format={n => formatShortTime(n, ['m', 's'], ' ')}
/>
</>
)}
</div>
);
}

View File

@ -4,7 +4,7 @@
}
@media only screen and (max-width: 992px) {
.container > div:last-child {
.bar > div:last-child {
display: none;
}
}

View File

@ -1,22 +1,21 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import Button from 'components/common/Button';
import Loading from 'components/common/Loading';
import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg';
import { get } from 'lib/web';
import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format';
import { useDateRange } from 'hooks/useDateRange';
import styles from './MetricsTable.module.css';
import Loading from '../common/Loading';
export default function MetricsTable({
title,
metric,
websiteId,
websiteDomain,
startDate,
endDate,
title,
metric,
type,
className,
dataFilter,
@ -27,7 +26,18 @@ export default function MetricsTable({
onDataLoad = () => {},
onExpand = () => {},
}) {
const [data, setData] = useState();
const dateRange = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/rankings`,
{
type,
start_at: +startDate,
end_at: +endDate,
domain: websiteDomain,
},
{ onDataLoad, delay: 300, update: [modified] },
);
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0;
@ -43,18 +53,6 @@ export default function MetricsTable({
return [];
}, [data, dataFilter, filterOptions]);
async function loadData() {
const data = await get(`/api/website/${websiteId}/rankings`, {
type,
start_at: +startDate,
end_at: +endDate,
domain: websiteDomain,
});
setData(data);
onDataLoad(data);
}
const handleSetFormat = () => setFormat(state => !state);
const getRow = row => {
@ -76,12 +74,6 @@ export default function MetricsTable({
return <div style={style}>{getRow(rankings[index])}</div>;
};
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId, startDate, endDate, type]);
return (
<div className={classNames(styles.container, className)}>
{data ? (

View File

@ -2,15 +2,13 @@ import React from 'react';
import MetricsTable from './MetricsTable';
import { osFilter } from 'lib/filters';
export default function OSTable({ websiteId, startDate, endDate, limit, onExpand }) {
export default function OSTable({ websiteId, limit, onExpand }) {
return (
<MetricsTable
title="Operating System"
type="os"
metric="Visitors"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={osFilter}
onExpand={onExpand}

View File

@ -3,14 +3,7 @@ import MetricsTable from './MetricsTable';
import { urlFilter } from 'lib/filters';
import ButtonGroup from '../common/ButtonGroup';
export default function PagesTable({
websiteId,
websiteDomain,
startDate,
endDate,
limit,
onExpand,
}) {
export default function PagesTable({ websiteId, websiteDomain, limit, onExpand }) {
const [filter, setFilter] = useState('Combined');
return (
@ -20,8 +13,6 @@ export default function PagesTable({
metric="Views"
headerComponent={limit ? null : <FilterButtons selected={filter} onClick={setFilter} />}
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={urlFilter}
filterOptions={{ domain: websiteDomain, raw: filter === 'Raw' }}

View File

@ -2,7 +2,7 @@ import React from 'react';
import CheckVisible from 'components/helpers/CheckVisible';
import BarChart from './BarChart';
export default function PageviewsChart({ websiteId, data, unit, className }) {
export default function PageviewsChart({ websiteId, data, unit, records, className }) {
const handleUpdate = chart => {
const {
data: { datasets },
@ -43,7 +43,7 @@ export default function PageviewsChart({ websiteId, data, unit, className }) {
},
]}
unit={unit}
records={data.pageviews.length}
records={records}
animationDuration={visible ? 300 : 0}
onUpdate={handleUpdate}
/>

View File

@ -12,8 +12,10 @@ const options = {
export default function QuickButtons({ value, onChange }) {
const selectedItem = Object.keys(options).find(key => options[key] === value);
function handleClick(value) {
onChange(getDateRange(options[value]));
function handleClick(selected) {
if (options[selected] !== value) {
onChange(getDateRange(options[selected]));
}
}
return (

View File

@ -1,16 +1,9 @@
import React, { useState } from 'react';
import MetricsTable from './MetricsTable';
import { refFilter } from 'lib/filters';
import ButtonGroup from '../common/ButtonGroup';
import ButtonGroup from 'components/common/ButtonGroup';
export default function Referrers({
websiteId,
websiteDomain,
startDate,
endDate,
limit,
onExpand = () => {},
}) {
export default function ReferrersTable({ websiteId, websiteDomain, limit, onExpand = () => {} }) {
const [filter, setFilter] = useState('Combined');
const renderLink = ({ x: url }) => {
@ -31,8 +24,6 @@ export default function Referrers({
headerComponent={limit ? null : <FilterButtons selected={filter} onClick={setFilter} />}
websiteId={websiteId}
websiteDomain={websiteDomain}
startDate={startDate}
endDate={endDate}
limit={limit}
dataFilter={refFilter}
filterOptions={{

View File

@ -1,24 +1,39 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons';
import DateFilter from '../common/DateFilter';
import StickyHeader from '../helpers/StickyHeader';
import { get } from 'lib/web';
import { getDateArray, getDateRange, getTimezone } from 'lib/date';
import DateFilter from 'components/common/DateFilter';
import StickyHeader from 'components/helpers/StickyHeader';
import useFetch from 'hooks/useFetch';
import { getDateArray, getDateLength, getTimezone } from 'lib/date';
import { setDateRange } from 'redux/actions/websites';
import styles from './WebsiteChart.module.css';
import WebsiteHeader from './WebsiteHeader';
import { useDateRange } from '../../hooks/useDateRange';
export default function WebsiteChart({
websiteId,
defaultDateRange = '7day',
title,
stickyHeader = false,
showLink = false,
onDataLoad = () => {},
onDateChange = () => {},
}) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate, unit, value } = dateRange;
const dispatch = useDispatch();
const dateRange = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/pageviews`,
{
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
},
{ onDataLoad, update: [modified] },
);
const [pageviews, uniques] = useMemo(() => {
if (data) {
@ -31,40 +46,19 @@ export default function WebsiteChart({
}, [data]);
function handleDateChange(values) {
setDateRange(values);
onDateChange(values);
dispatch(setDateRange(websiteId, values));
}
async function loadData() {
const data = await get(`/api/website/${websiteId}/pageviews`, {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
});
setData(data);
onDataLoad(data);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate, unit]);
return (
<>
<WebsiteHeader websiteId={websiteId} title={title} showLink={showLink} />
<div className={classNames(styles.header, 'row')}>
<StickyHeader
className={classNames(styles.metrics, 'col row')}
stickyClassName={styles.sticky}
enabled={stickyHeader}
>
<MetricsBar
className="col-12 col-md-9 col-lg-10"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
<MetricsBar className="col-12 col-md-9 col-lg-10" websiteId={websiteId} />
<DateFilter
className="col-12 col-md-3 col-lg-2"
value={value}
@ -74,7 +68,12 @@ export default function WebsiteChart({
</div>
<div className="row">
<div className="col">
<PageviewsChart websiteId={websiteId} data={{ pageviews, uniques }} unit={unit} />
<PageviewsChart
websiteId={websiteId}
data={{ pageviews, uniques }}
unit={unit}
records={getDateLength(startDate, endDate, unit)}
/>
<QuickButtons value={value} onChange={handleDateChange} />
</div>
</div>

View File

@ -1,38 +1,36 @@
import React from 'react';
import { useRouter } from 'next/router';
import PageHeader from 'components/layout/PageHeader';
import Link from 'components/common/Link';
import Button from 'components/common/Button';
import ActiveUsers from './ActiveUsers';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteHeader.module.css';
import RefreshButton from '../common/RefreshButton';
import ButtonLayout from '../layout/ButtonLayout';
export default function WebsiteHeader({ websiteId, name, showLink = false }) {
export default function WebsiteHeader({ websiteId, title, showLink = false }) {
const router = useRouter();
return (
<PageHeader>
{showLink ? (
<Link href="/website/[...id]" as={`/website/${websiteId}/${name}`} className={styles.title}>
{name}
</Link>
) : (
<div className={styles.title}>{name}</div>
)}
<div className={styles.title}>{title}</div>
<ActiveUsers className={styles.active} websiteId={websiteId} />
{showLink && (
<Button
icon={<Arrow />}
onClick={() =>
router.push('/website/[...id]', `/website/${websiteId}/${name}`, {
shallow: true,
})
}
size="small"
>
<div>View details</div>
</Button>
)}
<ButtonLayout>
<RefreshButton websiteId={websiteId} />
{showLink && (
<Button
icon={<Arrow />}
onClick={() =>
router.push('/website/[...id]', `/website/${websiteId}/${name}`, {
shallow: true,
})
}
size="small"
>
<div>View details</div>
</Button>
)}
</ButtonLayout>
</PageHeader>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import PageHeader from 'components/layout/PageHeader';
import Button from 'components/common/Button';
@ -7,20 +7,22 @@ import Table from 'components/common/Table';
import Modal from 'components/common/Modal';
import AccountEditForm from 'components/forms/AccountEditForm';
import ButtonLayout from 'components/layout/ButtonLayout';
import DeleteForm from 'components/forms/DeleteForm';
import useFetch from 'hooks/useFetch';
import Pen from 'assets/pen.svg';
import Plus from 'assets/plus.svg';
import Trash from 'assets/trash.svg';
import Check from 'assets/check.svg';
import { get } from 'lib/web';
import styles from './AccountSettings.module.css';
import DeleteForm from '../forms/DeleteForm';
import Toast from '../common/Toast';
export default function AccountSettings() {
const [data, setData] = useState();
const [addAccount, setAddAccount] = useState();
const [editAccount, setEditAccount] = useState();
const [deleteAccount, setDeleteAccount] = useState();
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch(`/api/accounts`, {}, { update: [saved] });
const Checkmark = ({ is_admin }) => (is_admin ? <Icon icon={<Check />} size="medium" /> : null);
@ -52,6 +54,7 @@ export default function AccountSettings() {
function handleSave() {
setSaved(state => state + 1);
setMessage('Saved successfully.');
handleClose();
}
@ -61,14 +64,6 @@ export default function AccountSettings() {
setDeleteAccount(null);
}
async function loadData() {
setData(await get(`/api/accounts`));
}
useEffect(() => {
loadData();
}, [saved]);
if (!data) {
return null;
}
@ -105,6 +100,7 @@ export default function AccountSettings() {
/>
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

View File

@ -5,12 +5,19 @@ import Button from 'components/common/Button';
import ChangePasswordForm from '../forms/ChangePasswordForm';
import Modal from 'components/common/Modal';
import Dots from 'assets/ellipsis-h.svg';
import Toast from '../common/Toast';
export default function ProfileSettings() {
const user = useSelector(state => state.user);
const [changePassword, setChangePassword] = useState(false);
const [message, setMessage] = useState();
const { user_id } = user;
function handleSave() {
setChangePassword(false);
setMessage('Saved successfully.');
}
return (
<>
<PageHeader>
@ -27,11 +34,12 @@ export default function ProfileSettings() {
<Modal title="Change password">
<ChangePasswordForm
values={{ user_id }}
onSave={() => setChangePassword(false)}
onSave={handleSave}
onClose={() => setChangePassword(false)}
/>
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import Table from 'components/common/Table';
import Button from 'components/common/Button';
@ -15,17 +15,19 @@ import Trash from 'assets/trash.svg';
import Plus from 'assets/plus.svg';
import Code from 'assets/code.svg';
import Link from 'assets/link.svg';
import { get } from 'lib/web';
import styles from './WebsiteSettings.module.css';
import useFetch from '../../hooks/useFetch';
import Toast from '../common/Toast';
export default function WebsiteSettings() {
const [data, setData] = useState();
const [editWebsite, setEditWebsite] = useState();
const [deleteWebsite, setDeleteWebsite] = useState();
const [addWebsite, setAddWebsite] = useState();
const [showCode, setShowCode] = useState();
const [showUrl, setShowUrl] = useState();
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch(`/api/websites`, {}, { update: [saved] });
const Buttons = row => (
<ButtonLayout>
@ -66,6 +68,7 @@ export default function WebsiteSettings() {
function handleSave() {
setSaved(state => state + 1);
setMessage('Saved successfully.');
handleClose();
}
@ -77,14 +80,6 @@ export default function WebsiteSettings() {
setShowUrl(null);
}
async function loadData() {
setData(await get(`/api/websites`));
}
useEffect(() => {
loadData();
}, [saved]);
if (!data) {
return null;
}
@ -135,6 +130,7 @@ export default function WebsiteSettings() {
<ShareUrlForm values={showUrl} onClose={handleClose} />
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

8
hooks/useDateRange.js Normal file
View File

@ -0,0 +1,8 @@
import { useSelector } from 'react-redux';
import { getDateRange } from 'lib/date';
export function useDateRange(websiteId, defaultDateRange = '7day') {
return useSelector(
state => state.websites[websiteId]?.dateRange || getDateRange(defaultDateRange),
);
}

39
hooks/useFetch.js Normal file
View File

@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { get } from 'lib/web';
export default function useFetch(url, params = {}, options = {}) {
const [data, setData] = useState();
const [error, setError] = useState();
const keys = Object.keys(params)
.sort()
.map(key => params[key]);
const { update = [], onDataLoad = () => {} } = options;
async function loadData() {
try {
setError(null);
const data = await get(url, params);
setData(data);
onDataLoad(data);
} catch (e) {
console.error(e);
setError(e);
}
}
useEffect(() => {
if (url) {
const { interval, delay = 0 } = options;
setTimeout(() => loadData(), delay);
const id = interval ? setInterval(() => loadData(), interval) : null;
return () => {
clearInterval(id);
};
}
}, [url, ...keys, ...update]);
return { data, error, loadData };
}

View File

@ -1,39 +1,30 @@
export const AUTH_COOKIE_NAME = 'umami.auth';
export const POSTGRESQL = 'postgresql';
export const MYSQL = 'mysql';
export const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
export const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
export const DOMAIN_REGEX = /((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}/;
export const DESKTOP_SCREEN_WIDTH = 1920;
export const LAPTOP_SCREEN_WIDTH = 1024;
export const MOBILE_SCREEN_WIDTH = 479;
export const OPERATING_SYSTEMS = [
'iOS',
'Android OS',
'BlackBerry OS',
'Windows Mobile',
'Amazon OS',
'Windows 3.11',
'Windows 95',
'Windows 98',
'Windows 2000',
'Windows XP',
'Windows Server 2003',
'Windows Vista',
'Windows 7',
'Windows 8',
'Windows 8.1',
'Windows 10',
'Windows ME',
'Open BSD',
'Sun OS',
'Linux',
'Mac OS',
'QNX',
'BeOS',
'OS/2',
'Chrome OS',
];
export const DESKTOP_OS = [
'Windows 3.11',
'Windows 95',

View File

@ -1,25 +1,7 @@
import moment from 'moment-timezone';
import prisma, { runQuery } from 'lib/db';
import { subMinutes } from 'date-fns';
const POSTGRESQL = 'postgresql';
const MYSQL = 'mysql';
const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
import { MYSQL, POSTGRESQL, MYSQL_DATE_FORMATS, POSTGRESQL_DATE_FORMATS } from 'lib/constants';
export function getDatabase() {
return (
@ -317,7 +299,7 @@ export function getMetrics(website_id, start_at, end_at) {
);
}
return Promise.resolve({});
return Promise.reject(new Error('Unknown database.'));
}
export function getPageviews(
@ -364,7 +346,7 @@ export function getPageviews(
);
}
return Promise.resolve([]);
return Promise.reject(new Error('Unknown database.'));
}
export function getRankings(website_id, start_at, end_at, type, table, domain) {
@ -406,7 +388,7 @@ export function getRankings(website_id, start_at, end_at, type, table, domain) {
);
}
return Promise.resolve([]);
return Promise.reject(new Error('Unknown database.'));
}
export function getActiveVisitors(website_id) {
@ -439,7 +421,7 @@ export function getActiveVisitors(website_id) {
);
}
return Promise.resolve([]);
return Promise.reject(new Error('Unknown database.'));
}
export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = 'day') {
@ -483,5 +465,5 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit =
);
}
return Promise.resolve([]);
return Promise.reject(new Error('Unknown database.'));
}

View File

@ -1,11 +1,11 @@
export function removeTrailingSlash(url) {
return url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url;
return url && url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url;
}
export function getDomainName(str) {
try {
return new URL(str).hostname;
} catch {
} catch (e) {
return str;
}
}

View File

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "0.19.0",
"version": "0.20.0",
"description": "A simple, fast, website analytics alternative to Google Analytics. ",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",
@ -30,8 +30,7 @@
],
"**/*.css": [
"stylelint --fix",
"prettier --write",
"eslint"
"prettier --write"
]
},
"husky": {
@ -53,6 +52,7 @@
"dotenv": "^8.2.0",
"formik": "^2.1.5",
"geolite2-redist": "^1.0.7",
"immer": "^7.0.8",
"is-localhost-ip": "^1.4.0",
"jose": "^1.28.0",
"maxmind": "^4.1.4",

View File

@ -3,11 +3,14 @@ import { ok } from 'lib/response';
export default async (req, res) => {
const { id, start_at, end_at } = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const metrics = await getMetrics(+id, new Date(+start_at), new Date(+end_at));
const metrics = await getMetrics(websiteId, startDate, endDate);
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
obj[key] = +metrics[0][key];
obj[key] = Number(metrics[0][key]) || 0;
return obj;
}, {});

View File

@ -1,46 +1,22 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout';
import WebsiteDetails from 'components/WebsiteDetails';
import NotFound from 'pages/404';
import { get } from 'lib/web';
import useFetch from 'hooks/useFetch';
export default function SharePage() {
const [loading, setLoading] = useState(true);
const [websiteId, setWebsiteId] = useState();
const [notFound, setNotFound] = useState(false);
const router = useRouter();
const { id } = router.query;
const shareId = id?.[0];
const { data } = useFetch(shareId ? `/api/share/${shareId}` : null);
async function loadData() {
const website = await get(`/api/share/${id?.[0]}`);
if (website) {
setWebsiteId(website.website_id);
} else if (typeof window !== 'undefined') {
setNotFound(true);
}
}
useEffect(() => {
if (id) {
loadData().finally(() => {
setLoading(false);
});
} else {
setLoading(false);
}
}, [id]);
if (loading) return null;
if (!id || notFound) {
return <NotFound />;
if (!data) {
return null;
}
return (
<Layout>
<WebsiteDetails websiteId={websiteId} />
<WebsiteDetails websiteId={data.website_id} />
</Layout>
);
}

33
redux/actions/websites.js Normal file
View File

@ -0,0 +1,33 @@
import { createSlice } from '@reduxjs/toolkit';
import produce from 'immer';
const websites = createSlice({
name: 'user',
initialState: {},
reducers: {
updateWebsites(state, action) {
state = action.payload;
return state;
},
},
});
export const { updateWebsites } = websites.actions;
export default websites.reducer;
export function setDateRange(websiteId, dateRange) {
return (dispatch, getState) => {
const state = getState();
let { websites = {} } = state;
websites = produce(websites, draft => {
if (!draft[websiteId]) {
draft[websiteId] = {};
}
draft[websiteId].dateRange = { ...dateRange, modified: Date.now() };
});
return dispatch(updateWebsites(websites));
};
}

View File

@ -1,4 +1,5 @@
import { combineReducers } from 'redux';
import user from './actions/user';
import websites from './actions/websites';
export default combineReducers({ user });
export default combineReducers({ user, websites });

View File

@ -5,8 +5,8 @@ const path = require('path');
const databaseType =
process.env.DATABASE_TYPE || (process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]);
if (!databaseType) {
throw new Error('Database schema not specified');
if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) {
throw new Error('Missing or invalid database');
}
console.log(`Database schema detected: ${databaseType}`);

View File

@ -1,6 +1,7 @@
import 'promise-polyfill/src/polyfill';
import 'unfetch/polyfill';
import { post, hook, doNotTrack } from '../lib/web';
import { removeTrailingSlash } from '../lib/url';
(window => {
const {
@ -17,7 +18,10 @@ import { post, hook, doNotTrack } from '../lib/web';
if (!script || (__DNT__ && doNotTrack())) return;
const website = script.getAttribute('data-website-id');
const hostUrl = new URL(script.src).href.split('/').slice(0, -1).join('/');
const hostUrl = script.getAttribute('data-host-url');
const root = hostUrl
? removeTrailingSlash(hostUrl)
: new URL(script.src).href.split('/').slice(0, -1).join('/');
const screen = `${width}x${height}`;
const listeners = [];
@ -42,7 +46,7 @@ import { post, hook, doNotTrack } from '../lib/web';
});
}
return post(`${hostUrl}/api/collect`, {
return post(`${root}/api/collect`, {
type,
payload,
});

View File

@ -4456,7 +4456,7 @@ ignore@^5.1.4, ignore@^5.1.8:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
immer@^7.0.3:
immer@^7.0.3, immer@^7.0.8:
version "7.0.8"
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.8.tgz#41dcbc5669a76500d017bef3ad0d03ce0a1d7c1e"
integrity sha512-XnpIN8PXBBaOD43U8Z17qg6RQiKQYGDGGCIbz1ixmLGwBkSWwmrmx5X7d+hTtXDM8ur7m5OdLE0PiO+y5RB3pw==