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

View File

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

View File

@ -20,10 +20,12 @@ export default function DropDown({
setShowMenu(state => !state); setShowMenu(state => !state);
} }
function handleSelect(value, e) { function handleSelect(selected, e) {
e.stopPropagation(); e.stopPropagation();
setShowMenu(false); setShowMenu(false);
onChange(value); if (selected !== value) {
onChange(selected);
}
} }
useDocumentClick(e => { 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 { useSpring, animated } from 'react-spring';
import classNames from 'classnames'; import classNames from 'classnames';
import { get } from 'lib/web'; import useFetch from 'hooks/useFetch';
import styles from './ActiveUsers.module.css'; import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className }) { export default function ActiveUsers({ websiteId, className }) {
const [count, setCount] = useState(0); const { data } = useFetch(`/api/website/${websiteId}/active`, {}, { interval: 60000 });
const count = useMemo(() => {
async function loadData() { return data?.[0]?.x || 0;
const result = await get(`/api/website/${websiteId}/active`); }, [data]);
setCount(result?.[0]?.x);
}
const props = useSpring({ const props = useSpring({
x: count, x: count,
from: { x: 0 }, from: { x: 0 },
}); });
useEffect(() => {
loadData();
const id = setInterval(() => loadData(), 60000);
return () => {
clearInterval(id);
};
}, []);
if (count === 0) { if (count === 0) {
return null; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useMemo } from 'react';
import classNames from 'classnames';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import BarChart from './BarChart'; import BarChart from './BarChart';
import { get } from 'lib/web';
import { getTimezone, getDateArray, getDateLength } from 'lib/date'; 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 = [ const COLORS = [
'#2680eb', '#2680eb',
@ -17,31 +16,21 @@ const COLORS = [
'#85d044', '#85d044',
]; ];
export default function EventsChart({ websiteId, startDate, endDate, unit }) { export default function EventsChart({ websiteId }) {
const [data, setData] = useState(); const dateRange = useDateRange(websiteId);
const datasets = useMemo(() => { const { startDate, endDate, unit, modified } = dateRange;
if (!data) return []; const { data } = useFetch(
`/api/website/${websiteId}/events`,
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`, {
start_at: +startDate, start_at: +startDate,
end_at: +endDate, end_at: +endDate,
unit, unit,
tz: getTimezone(), tz: getTimezone(),
}); },
{ update: [modified] },
);
const datasets = useMemo(() => {
if (!data) return [];
const map = data.reduce((obj, { x, t, y }) => { const map = data.reduce((obj, { x, t, y }) => {
if (!obj[x]) { if (!obj[x]) {
@ -57,8 +46,18 @@ export default function EventsChart({ websiteId, startDate, endDate, unit }) {
map[key] = getDateArray(map[key], 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) { function handleCreate(options) {
const legend = { const legend = {
@ -74,10 +73,6 @@ export default function EventsChart({ websiteId, startDate, endDate, unit }) {
chart.update(); chart.update();
} }
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate]);
if (!data) { if (!data) {
return null; return null;
} }

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
} }
@media only screen and (max-width: 992px) { @media only screen and (max-width: 992px) {
.container > div:last-child { .bar > div:last-child {
display: none; 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 { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring'; import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames'; import classNames from 'classnames';
import Button from 'components/common/Button'; 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 Arrow from 'assets/arrow-right.svg';
import { get } from 'lib/web';
import { percentFilter } from 'lib/filters'; import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format'; import { formatNumber, formatLongNumber } from 'lib/format';
import { useDateRange } from 'hooks/useDateRange';
import styles from './MetricsTable.module.css'; import styles from './MetricsTable.module.css';
import Loading from '../common/Loading';
export default function MetricsTable({ export default function MetricsTable({
title,
metric,
websiteId, websiteId,
websiteDomain, websiteDomain,
startDate, title,
endDate, metric,
type, type,
className, className,
dataFilter, dataFilter,
@ -27,7 +26,18 @@ export default function MetricsTable({
onDataLoad = () => {}, onDataLoad = () => {},
onExpand = () => {}, 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 [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber; const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0; const shouldAnimate = limit > 0;
@ -43,18 +53,6 @@ export default function MetricsTable({
return []; return [];
}, [data, dataFilter, filterOptions]); }, [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 handleSetFormat = () => setFormat(state => !state);
const getRow = row => { const getRow = row => {
@ -76,12 +74,6 @@ export default function MetricsTable({
return <div style={style}>{getRow(rankings[index])}</div>; return <div style={style}>{getRow(rankings[index])}</div>;
}; };
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId, startDate, endDate, type]);
return ( return (
<div className={classNames(styles.container, className)}> <div className={classNames(styles.container, className)}>
{data ? ( {data ? (

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import MetricsTable from './MetricsTable'; import MetricsTable from './MetricsTable';
import { refFilter } from 'lib/filters'; import { refFilter } from 'lib/filters';
import ButtonGroup from '../common/ButtonGroup'; import ButtonGroup from 'components/common/ButtonGroup';
export default function Referrers({ export default function ReferrersTable({ websiteId, websiteDomain, limit, onExpand = () => {} }) {
websiteId,
websiteDomain,
startDate,
endDate,
limit,
onExpand = () => {},
}) {
const [filter, setFilter] = useState('Combined'); const [filter, setFilter] = useState('Combined');
const renderLink = ({ x: url }) => { const renderLink = ({ x: url }) => {
@ -31,8 +24,6 @@ export default function Referrers({
headerComponent={limit ? null : <FilterButtons selected={filter} onClick={setFilter} />} headerComponent={limit ? null : <FilterButtons selected={filter} onClick={setFilter} />}
websiteId={websiteId} websiteId={websiteId}
websiteDomain={websiteDomain} websiteDomain={websiteDomain}
startDate={startDate}
endDate={endDate}
limit={limit} limit={limit}
dataFilter={refFilter} dataFilter={refFilter}
filterOptions={{ 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 classNames from 'classnames';
import PageviewsChart from './PageviewsChart'; import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar'; import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons'; import QuickButtons from './QuickButtons';
import DateFilter from '../common/DateFilter'; import DateFilter from 'components/common/DateFilter';
import StickyHeader from '../helpers/StickyHeader'; import StickyHeader from 'components/helpers/StickyHeader';
import { get } from 'lib/web'; import useFetch from 'hooks/useFetch';
import { getDateArray, getDateRange, getTimezone } from 'lib/date'; import { getDateArray, getDateLength, getTimezone } from 'lib/date';
import { setDateRange } from 'redux/actions/websites';
import styles from './WebsiteChart.module.css'; import styles from './WebsiteChart.module.css';
import WebsiteHeader from './WebsiteHeader';
import { useDateRange } from '../../hooks/useDateRange';
export default function WebsiteChart({ export default function WebsiteChart({
websiteId, websiteId,
defaultDateRange = '7day', title,
stickyHeader = false, stickyHeader = false,
showLink = false,
onDataLoad = () => {}, onDataLoad = () => {},
onDateChange = () => {},
}) { }) {
const [data, setData] = useState(); const dispatch = useDispatch();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange)); const dateRange = useDateRange(websiteId);
const { startDate, endDate, unit, value } = dateRange; 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(() => { const [pageviews, uniques] = useMemo(() => {
if (data) { if (data) {
@ -31,40 +46,19 @@ export default function WebsiteChart({
}, [data]); }, [data]);
function handleDateChange(values) { function handleDateChange(values) {
setDateRange(values); dispatch(setDateRange(websiteId, values));
onDateChange(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 ( return (
<> <>
<WebsiteHeader websiteId={websiteId} title={title} showLink={showLink} />
<div className={classNames(styles.header, 'row')}> <div className={classNames(styles.header, 'row')}>
<StickyHeader <StickyHeader
className={classNames(styles.metrics, 'col row')} className={classNames(styles.metrics, 'col row')}
stickyClassName={styles.sticky} stickyClassName={styles.sticky}
enabled={stickyHeader} enabled={stickyHeader}
> >
<MetricsBar <MetricsBar className="col-12 col-md-9 col-lg-10" websiteId={websiteId} />
className="col-12 col-md-9 col-lg-10"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
<DateFilter <DateFilter
className="col-12 col-md-3 col-lg-2" className="col-12 col-md-3 col-lg-2"
value={value} value={value}
@ -74,7 +68,12 @@ export default function WebsiteChart({
</div> </div>
<div className="row"> <div className="row">
<div className="col"> <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} /> <QuickButtons value={value} onChange={handleDateChange} />
</div> </div>
</div> </div>

View File

@ -1,25 +1,22 @@
import React from 'react'; import React from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import Link from 'components/common/Link';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import ActiveUsers from './ActiveUsers'; import ActiveUsers from './ActiveUsers';
import Arrow from 'assets/arrow-right.svg'; import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteHeader.module.css'; 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(); const router = useRouter();
return ( return (
<PageHeader> <PageHeader>
{showLink ? ( <div className={styles.title}>{title}</div>
<Link href="/website/[...id]" as={`/website/${websiteId}/${name}`} className={styles.title}>
{name}
</Link>
) : (
<div className={styles.title}>{name}</div>
)}
<ActiveUsers className={styles.active} websiteId={websiteId} /> <ActiveUsers className={styles.active} websiteId={websiteId} />
<ButtonLayout>
<RefreshButton websiteId={websiteId} />
{showLink && ( {showLink && (
<Button <Button
icon={<Arrow />} icon={<Arrow />}
@ -33,6 +30,7 @@ export default function WebsiteHeader({ websiteId, name, showLink = false }) {
<div>View details</div> <div>View details</div>
</Button> </Button>
)} )}
</ButtonLayout>
</PageHeader> </PageHeader>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
@ -7,20 +7,22 @@ import Table from 'components/common/Table';
import Modal from 'components/common/Modal'; import Modal from 'components/common/Modal';
import AccountEditForm from 'components/forms/AccountEditForm'; import AccountEditForm from 'components/forms/AccountEditForm';
import ButtonLayout from 'components/layout/ButtonLayout'; import ButtonLayout from 'components/layout/ButtonLayout';
import DeleteForm from 'components/forms/DeleteForm';
import useFetch from 'hooks/useFetch';
import Pen from 'assets/pen.svg'; import Pen from 'assets/pen.svg';
import Plus from 'assets/plus.svg'; import Plus from 'assets/plus.svg';
import Trash from 'assets/trash.svg'; import Trash from 'assets/trash.svg';
import Check from 'assets/check.svg'; import Check from 'assets/check.svg';
import { get } from 'lib/web';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
import DeleteForm from '../forms/DeleteForm'; import Toast from '../common/Toast';
export default function AccountSettings() { export default function AccountSettings() {
const [data, setData] = useState();
const [addAccount, setAddAccount] = useState(); const [addAccount, setAddAccount] = useState();
const [editAccount, setEditAccount] = useState(); const [editAccount, setEditAccount] = useState();
const [deleteAccount, setDeleteAccount] = useState(); const [deleteAccount, setDeleteAccount] = useState();
const [saved, setSaved] = useState(0); 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); const Checkmark = ({ is_admin }) => (is_admin ? <Icon icon={<Check />} size="medium" /> : null);
@ -52,6 +54,7 @@ export default function AccountSettings() {
function handleSave() { function handleSave() {
setSaved(state => state + 1); setSaved(state => state + 1);
setMessage('Saved successfully.');
handleClose(); handleClose();
} }
@ -61,14 +64,6 @@ export default function AccountSettings() {
setDeleteAccount(null); setDeleteAccount(null);
} }
async function loadData() {
setData(await get(`/api/accounts`));
}
useEffect(() => {
loadData();
}, [saved]);
if (!data) { if (!data) {
return null; return null;
} }
@ -105,6 +100,7 @@ export default function AccountSettings() {
/> />
</Modal> </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 ChangePasswordForm from '../forms/ChangePasswordForm';
import Modal from 'components/common/Modal'; import Modal from 'components/common/Modal';
import Dots from 'assets/ellipsis-h.svg'; import Dots from 'assets/ellipsis-h.svg';
import Toast from '../common/Toast';
export default function ProfileSettings() { export default function ProfileSettings() {
const user = useSelector(state => state.user); const user = useSelector(state => state.user);
const [changePassword, setChangePassword] = useState(false); const [changePassword, setChangePassword] = useState(false);
const [message, setMessage] = useState();
const { user_id } = user; const { user_id } = user;
function handleSave() {
setChangePassword(false);
setMessage('Saved successfully.');
}
return ( return (
<> <>
<PageHeader> <PageHeader>
@ -27,11 +34,12 @@ export default function ProfileSettings() {
<Modal title="Change password"> <Modal title="Change password">
<ChangePasswordForm <ChangePasswordForm
values={{ user_id }} values={{ user_id }}
onSave={() => setChangePassword(false)} onSave={handleSave}
onClose={() => setChangePassword(false)} onClose={() => setChangePassword(false)}
/> />
</Modal> </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 classNames from 'classnames';
import Table from 'components/common/Table'; import Table from 'components/common/Table';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
@ -15,17 +15,19 @@ import Trash from 'assets/trash.svg';
import Plus from 'assets/plus.svg'; import Plus from 'assets/plus.svg';
import Code from 'assets/code.svg'; import Code from 'assets/code.svg';
import Link from 'assets/link.svg'; import Link from 'assets/link.svg';
import { get } from 'lib/web';
import styles from './WebsiteSettings.module.css'; import styles from './WebsiteSettings.module.css';
import useFetch from '../../hooks/useFetch';
import Toast from '../common/Toast';
export default function WebsiteSettings() { export default function WebsiteSettings() {
const [data, setData] = useState();
const [editWebsite, setEditWebsite] = useState(); const [editWebsite, setEditWebsite] = useState();
const [deleteWebsite, setDeleteWebsite] = useState(); const [deleteWebsite, setDeleteWebsite] = useState();
const [addWebsite, setAddWebsite] = useState(); const [addWebsite, setAddWebsite] = useState();
const [showCode, setShowCode] = useState(); const [showCode, setShowCode] = useState();
const [showUrl, setShowUrl] = useState(); const [showUrl, setShowUrl] = useState();
const [saved, setSaved] = useState(0); const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch(`/api/websites`, {}, { update: [saved] });
const Buttons = row => ( const Buttons = row => (
<ButtonLayout> <ButtonLayout>
@ -66,6 +68,7 @@ export default function WebsiteSettings() {
function handleSave() { function handleSave() {
setSaved(state => state + 1); setSaved(state => state + 1);
setMessage('Saved successfully.');
handleClose(); handleClose();
} }
@ -77,14 +80,6 @@ export default function WebsiteSettings() {
setShowUrl(null); setShowUrl(null);
} }
async function loadData() {
setData(await get(`/api/websites`));
}
useEffect(() => {
loadData();
}, [saved]);
if (!data) { if (!data) {
return null; return null;
} }
@ -135,6 +130,7 @@ export default function WebsiteSettings() {
<ShareUrlForm values={showUrl} onClose={handleClose} /> <ShareUrlForm values={showUrl} onClose={handleClose} />
</Modal> </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 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 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 DESKTOP_SCREEN_WIDTH = 1920;
export const LAPTOP_SCREEN_WIDTH = 1024; export const LAPTOP_SCREEN_WIDTH = 1024;
export const MOBILE_SCREEN_WIDTH = 479; 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 = [ export const DESKTOP_OS = [
'Windows 3.11', 'Windows 3.11',
'Windows 95', 'Windows 95',

View File

@ -1,25 +1,7 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import prisma, { runQuery } from 'lib/db'; import prisma, { runQuery } from 'lib/db';
import { subMinutes } from 'date-fns'; import { subMinutes } from 'date-fns';
import { MYSQL, POSTGRESQL, MYSQL_DATE_FORMATS, POSTGRESQL_DATE_FORMATS } from 'lib/constants';
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',
};
export function getDatabase() { export function getDatabase() {
return ( 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( 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) { 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) { 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') { 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) { 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) { export function getDomainName(str) {
try { try {
return new URL(str).hostname; return new URL(str).hostname;
} catch { } catch (e) {
return str; return str;
} }
} }

View File

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

View File

@ -3,11 +3,14 @@ import { ok } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
const { id, start_at, end_at } = req.query; 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) => { const stats = Object.keys(metrics[0]).reduce((obj, key) => {
obj[key] = +metrics[0][key]; obj[key] = Number(metrics[0][key]) || 0;
return obj; return obj;
}, {}); }, {});

View File

@ -1,46 +1,22 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout'; import Layout from 'components/layout/Layout';
import WebsiteDetails from 'components/WebsiteDetails'; import WebsiteDetails from 'components/WebsiteDetails';
import NotFound from 'pages/404'; import useFetch from 'hooks/useFetch';
import { get } from 'lib/web';
export default function SharePage() { export default function SharePage() {
const [loading, setLoading] = useState(true);
const [websiteId, setWebsiteId] = useState();
const [notFound, setNotFound] = useState(false);
const router = useRouter(); const router = useRouter();
const { id } = router.query; const { id } = router.query;
const shareId = id?.[0];
const { data } = useFetch(shareId ? `/api/share/${shareId}` : null);
async function loadData() { if (!data) {
const website = await get(`/api/share/${id?.[0]}`); return null;
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 />;
} }
return ( return (
<Layout> <Layout>
<WebsiteDetails websiteId={websiteId} /> <WebsiteDetails websiteId={data.website_id} />
</Layout> </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 { combineReducers } from 'redux';
import user from './actions/user'; 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 = const databaseType =
process.env.DATABASE_TYPE || (process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]); process.env.DATABASE_TYPE || (process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]);
if (!databaseType) { if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) {
throw new Error('Database schema not specified'); throw new Error('Missing or invalid database');
} }
console.log(`Database schema detected: ${databaseType}`); console.log(`Database schema detected: ${databaseType}`);

View File

@ -1,6 +1,7 @@
import 'promise-polyfill/src/polyfill'; import 'promise-polyfill/src/polyfill';
import 'unfetch/polyfill'; import 'unfetch/polyfill';
import { post, hook, doNotTrack } from '../lib/web'; import { post, hook, doNotTrack } from '../lib/web';
import { removeTrailingSlash } from '../lib/url';
(window => { (window => {
const { const {
@ -17,7 +18,10 @@ import { post, hook, doNotTrack } from '../lib/web';
if (!script || (__DNT__ && doNotTrack())) return; if (!script || (__DNT__ && doNotTrack())) return;
const website = script.getAttribute('data-website-id'); 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 screen = `${width}x${height}`;
const listeners = []; const listeners = [];
@ -42,7 +46,7 @@ import { post, hook, doNotTrack } from '../lib/web';
}); });
} }
return post(`${hostUrl}/api/collect`, { return post(`${root}/api/collect`, {
type, type,
payload, 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" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
immer@^7.0.3: immer@^7.0.3, immer@^7.0.8:
version "7.0.8" version "7.0.8"
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.8.tgz#41dcbc5669a76500d017bef3ad0d03ce0a1d7c1e" resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.8.tgz#41dcbc5669a76500d017bef3ad0d03ce0a1d7c1e"
integrity sha512-XnpIN8PXBBaOD43U8Z17qg6RQiKQYGDGGCIbz1ixmLGwBkSWwmrmx5X7d+hTtXDM8ur7m5OdLE0PiO+y5RB3pw== integrity sha512-XnpIN8PXBBaOD43U8Z17qg6RQiKQYGDGGCIbz1ixmLGwBkSWwmrmx5X7d+hTtXDM8ur7m5OdLE0PiO+y5RB3pw==