mirror of
https://github.com/kremalicious/umami.git
synced 2024-12-18 15:23:38 +01:00
commit
85f791e382
@ -1,12 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
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 MenuLayout from 'components/layout/MenuLayout';
|
import MenuLayout from 'components/layout/MenuLayout';
|
||||||
import Button from 'components/common/Button';
|
import Link from 'components/common/Link';
|
||||||
|
import Loading from 'components/common/Loading';
|
||||||
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,7 +18,7 @@ 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 useFetch from 'hooks/useFetch';
|
||||||
import Loading from 'components/common/Loading';
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
|
|
||||||
const views = {
|
const views = {
|
||||||
url: PagesTable,
|
url: PagesTable,
|
||||||
@ -31,31 +31,26 @@ const views = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WebsiteDetails({ websiteId, token }) {
|
export default function WebsiteDetails({ websiteId, token }) {
|
||||||
const router = useRouter();
|
|
||||||
const { data } = useFetch(`/api/website/${websiteId}`, { token });
|
const { data } = useFetch(`/api/website/${websiteId}`, { token });
|
||||||
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 {
|
const {
|
||||||
query: { id, view },
|
resolve,
|
||||||
basePath,
|
query: { view },
|
||||||
asPath,
|
} = usePageQuery();
|
||||||
} = router;
|
|
||||||
|
|
||||||
const path = `${basePath}/${asPath.split('/')[1]}/${id.join('/')}`;
|
|
||||||
|
|
||||||
const BackButton = () => (
|
const BackButton = () => (
|
||||||
<Button
|
<Link
|
||||||
key="back-button"
|
key="back-button"
|
||||||
className={styles.backButton}
|
className={styles.backButton}
|
||||||
|
href="/website/[...id]"
|
||||||
|
as={resolve({ view: undefined })}
|
||||||
icon={<Arrow />}
|
icon={<Arrow />}
|
||||||
size="xsmall"
|
size="small"
|
||||||
onClick={() => router.push(path)}
|
|
||||||
>
|
>
|
||||||
<div>
|
<FormattedMessage id="button.back" defaultMessage="Back" />
|
||||||
<FormattedMessage id="button.back" defaultMessage="Back" />
|
</Link>
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const menuOptions = [
|
const menuOptions = [
|
||||||
@ -64,31 +59,31 @@ export default function WebsiteDetails({ websiteId, token }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
|
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
|
||||||
value: `${path}?view=url`,
|
value: resolve({ view: 'url' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
||||||
value: `${path}?view=referrer`,
|
value: resolve({ view: 'referrer' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />,
|
label: <FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />,
|
||||||
value: `${path}?view=browser`,
|
value: resolve({ view: 'browser' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />,
|
label: <FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />,
|
||||||
value: `${path}?view=os`,
|
value: resolve({ view: 'os' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.devices" defaultMessage="Devices" />,
|
label: <FormattedMessage id="metrics.devices" defaultMessage="Devices" />,
|
||||||
value: `${path}?view=device`,
|
value: resolve({ view: 'device' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.countries" defaultMessage="Countries" />,
|
label: <FormattedMessage id="metrics.countries" defaultMessage="Countries" />,
|
||||||
value: `${path}?view=country`,
|
value: resolve({ view: 'country' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
|
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
|
||||||
value: `${path}?view=event`,
|
value: resolve({ view: 'event' }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -97,7 +92,6 @@ export default function WebsiteDetails({ websiteId, token }) {
|
|||||||
token,
|
token,
|
||||||
websiteDomain: data?.domain,
|
websiteDomain: data?.domain,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
onExpand: handleExpand,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DetailsComponent = views[view];
|
const DetailsComponent = views[view];
|
||||||
@ -108,10 +102,6 @@ export default function WebsiteDetails({ websiteId, token }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExpand(value) {
|
|
||||||
router.push(`${path}?view=${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -179,7 +169,7 @@ export default function WebsiteDetails({ websiteId, token }) {
|
|||||||
contentClassName={styles.content}
|
contentClassName={styles.content}
|
||||||
menu={menuOptions}
|
menu={menuOptions}
|
||||||
>
|
>
|
||||||
<DetailsComponent {...tableProps} limit={false} />
|
<DetailsComponent {...tableProps} limit={false} showFilters={true} />
|
||||||
</MenuLayout>
|
</MenuLayout>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backButton {
|
.backButton {
|
||||||
align-self: flex-start;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,9 +34,7 @@ export default function WebsiteList({ userId }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button icon={<Arrow />} size="medium" onClick={() => router.push('/settings')}>
|
<Button icon={<Arrow />} size="medium" onClick={() => router.push('/settings')}>
|
||||||
<div>
|
<FormattedMessage id="message.go-to-settings" defaultMessage="Go to settings" />
|
||||||
<FormattedMessage id="message.go-to-settings" defaultMessage="Go to settings" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
)}
|
)}
|
||||||
|
@ -13,7 +13,8 @@ export default function Button({
|
|||||||
className,
|
className,
|
||||||
tooltip,
|
tooltip,
|
||||||
tooltipId,
|
tooltipId,
|
||||||
disabled = false,
|
disabled,
|
||||||
|
iconRight,
|
||||||
onClick = () => {},
|
onClick = () => {},
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
@ -30,14 +31,14 @@ export default function Button({
|
|||||||
[styles.action]: variant === 'action',
|
[styles.action]: variant === 'action',
|
||||||
[styles.danger]: variant === 'danger',
|
[styles.danger]: variant === 'danger',
|
||||||
[styles.light]: variant === 'light',
|
[styles.light]: variant === 'light',
|
||||||
[styles.disabled]: disabled,
|
[styles.iconRight]: iconRight,
|
||||||
})}
|
})}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={!disabled ? onClick : null}
|
onClick={!disabled ? onClick : null}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon && <Icon icon={icon} size={size} />}
|
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||||
{children}
|
{children && <div className={styles.label}>{children}</div>}
|
||||||
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,12 +21,15 @@
|
|||||||
color: var(--gray900);
|
color: var(--gray900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.large {
|
.label {
|
||||||
font-size: var(--font-size-large);
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medium {
|
.large {
|
||||||
font-size: var(--font-size-normal);
|
font-size: var(--font-size-large);
|
||||||
}
|
}
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
@ -38,7 +40,8 @@
|
|||||||
font-size: var(--font-size-xsmall);
|
font-size: var(--font-size-xsmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
.action,
|
||||||
|
.action:active {
|
||||||
color: var(--gray50);
|
color: var(--gray50);
|
||||||
background: var(--gray900);
|
background: var(--gray900);
|
||||||
}
|
}
|
||||||
@ -64,6 +67,19 @@
|
|||||||
background: inherit;
|
background: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button .icon + * {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.iconRight .icon {
|
||||||
|
order: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.iconRight .icon + * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: var(--gray500);
|
color: var(--gray500);
|
||||||
|
@ -5,10 +5,6 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon + * {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon svg {
|
.icon svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import NextLink from 'next/link';
|
import NextLink from 'next/link';
|
||||||
|
import Icon from './Icon';
|
||||||
import styles from './Link.module.css';
|
import styles from './Link.module.css';
|
||||||
|
|
||||||
export default function Link({ className, children, ...props }) {
|
export default function Link({ className, icon, children, size, iconRight, ...props }) {
|
||||||
return (
|
return (
|
||||||
<NextLink {...props}>
|
<NextLink {...props}>
|
||||||
<a className={classNames(styles.link, className)}>{children}</a>
|
<a
|
||||||
|
className={classNames(styles.link, className, {
|
||||||
|
[styles.large]: size === 'large',
|
||||||
|
[styles.small]: size === 'small',
|
||||||
|
[styles.xsmall]: size === 'xsmall',
|
||||||
|
[styles.iconRight]: iconRight,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
</NextLink>
|
</NextLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,8 @@ a.link:visited {
|
|||||||
position: relative;
|
position: relative;
|
||||||
color: var(--gray900);
|
color: var(--gray900);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.link:before {
|
a.link:before {
|
||||||
@ -21,3 +23,28 @@ a.link:hover:before {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
transition: width 100ms;
|
transition: width 100ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.link.large {
|
||||||
|
font-size: var(--font-size-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link.small {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link.xsmall {
|
||||||
|
font-size: var(--font-size-xsmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link .icon + * {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link.iconRight .icon {
|
||||||
|
order: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link.iconRight .icon + * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import { browserFilter } from 'lib/filters';
|
import { browserFilter } from 'lib/filters';
|
||||||
|
|
||||||
export default function BrowsersTable({ websiteId, token, limit, onExpand }) {
|
export default function BrowsersTable({ websiteId, token, limit }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />}
|
title={<FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />}
|
||||||
@ -13,7 +13,6 @@ export default function BrowsersTable({ websiteId, token, limit, onExpand }) {
|
|||||||
token={token}
|
token={token}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
dataFilter={browserFilter}
|
dataFilter={browserFilter}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,7 @@ import MetricsTable from './MetricsTable';
|
|||||||
import { countryFilter, percentFilter } from 'lib/filters';
|
import { countryFilter, percentFilter } from 'lib/filters';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default function CountriesTable({
|
export default function CountriesTable({ websiteId, token, limit, onDataLoad = () => {} }) {
|
||||||
websiteId,
|
|
||||||
token,
|
|
||||||
limit,
|
|
||||||
onDataLoad = () => {},
|
|
||||||
onExpand,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
||||||
@ -20,7 +14,6 @@ export default function CountriesTable({
|
|||||||
limit={limit}
|
limit={limit}
|
||||||
dataFilter={countryFilter}
|
dataFilter={countryFilter}
|
||||||
onDataLoad={data => onDataLoad(percentFilter(data))}
|
onDataLoad={data => onDataLoad(percentFilter(data))}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { deviceFilter } from 'lib/filters';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { getDeviceMessage } from 'components/messages';
|
import { getDeviceMessage } from 'components/messages';
|
||||||
|
|
||||||
export default function DevicesTable({ websiteId, token, limit, onExpand }) {
|
export default function DevicesTable({ websiteId, token, limit }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.devices" defaultMessage="Devices" />}
|
title={<FormattedMessage id="metrics.devices" defaultMessage="Devices" />}
|
||||||
@ -15,7 +15,6 @@ export default function DevicesTable({ websiteId, token, limit, onExpand }) {
|
|||||||
limit={limit}
|
limit={limit}
|
||||||
dataFilter={deviceFilter}
|
dataFilter={deviceFilter}
|
||||||
renderLabel={({ x }) => getDeviceMessage(x)}
|
renderLabel={({ x }) => getDeviceMessage(x)}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,13 @@ import useFetch from 'hooks/useFetch';
|
|||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import useTimezone from 'hooks/useTimezone';
|
import useTimezone from 'hooks/useTimezone';
|
||||||
import { EVENT_COLORS } from 'lib/constants';
|
import { EVENT_COLORS } from 'lib/constants';
|
||||||
|
import usePageQuery from '../../hooks/usePageQuery';
|
||||||
|
|
||||||
export default function EventsChart({ websiteId, token }) {
|
export default function EventsChart({ websiteId, token }) {
|
||||||
const [dateRange] = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, unit, modified } = dateRange;
|
const { startDate, endDate, unit, modified } = dateRange;
|
||||||
const [timezone] = useTimezone();
|
const [timezone] = useTimezone();
|
||||||
|
const { query } = usePageQuery();
|
||||||
|
|
||||||
const { data } = useFetch(
|
const { data } = useFetch(
|
||||||
`/api/website/${websiteId}/events`,
|
`/api/website/${websiteId}/events`,
|
||||||
@ -19,6 +21,7 @@ export default function EventsChart({ websiteId, token }) {
|
|||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
unit,
|
unit,
|
||||||
tz: timezone,
|
tz: timezone,
|
||||||
|
url: query.url,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
{ update: [modified] },
|
{ update: [modified] },
|
||||||
|
@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import styles from './EventsTable.module.css';
|
import styles from './EventsTable.module.css';
|
||||||
|
|
||||||
export default function EventsTable({ websiteId, token, limit, onExpand, onDataLoad }) {
|
export default function EventsTable({ websiteId, token, limit, onDataLoad }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
||||||
@ -13,7 +13,6 @@ export default function EventsTable({ websiteId, token, limit, onExpand, onDataL
|
|||||||
token={token}
|
token={token}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
renderLabel={({ x }) => <Label value={x} />}
|
renderLabel={({ x }) => <Label value={x} />}
|
||||||
onExpand={onExpand}
|
|
||||||
onDataLoad={onDataLoad}
|
onDataLoad={onDataLoad}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -5,24 +5,30 @@ import Loading from 'components/common/Loading';
|
|||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
|
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import MetricCard from './MetricCard';
|
import MetricCard from './MetricCard';
|
||||||
import styles from './MetricsBar.module.css';
|
import styles from './MetricsBar.module.css';
|
||||||
|
|
||||||
export default function MetricsBar({ websiteId, token, className }) {
|
export default function MetricsBar({ websiteId, token, className }) {
|
||||||
const [dateRange] = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, modified } = dateRange;
|
const { startDate, endDate, modified } = dateRange;
|
||||||
|
const [format, setFormat] = useState(true);
|
||||||
|
const {
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
const { data } = useFetch(
|
const { data } = useFetch(
|
||||||
`/api/website/${websiteId}/metrics`,
|
`/api/website/${websiteId}/metrics`,
|
||||||
{
|
{
|
||||||
start_at: +startDate,
|
start_at: +startDate,
|
||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
|
url,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
update: [modified],
|
update: [modified],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [format, setFormat] = useState(true);
|
|
||||||
|
|
||||||
const formatFunc = format ? formatLongNumber : formatNumber;
|
const formatFunc = format ? formatLongNumber : formatNumber;
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
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 Link from 'components/common/Link';
|
||||||
import Loading from 'components/common/Loading';
|
import Loading from 'components/common/Loading';
|
||||||
import NoData from 'components/common/NoData';
|
import NoData from 'components/common/NoData';
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
@ -11,6 +11,7 @@ import Arrow from 'assets/arrow-right.svg';
|
|||||||
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 useDateRange from 'hooks/useDateRange';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import styles from './MetricsTable.module.css';
|
import styles from './MetricsTable.module.css';
|
||||||
|
|
||||||
export default function MetricsTable({
|
export default function MetricsTable({
|
||||||
@ -26,10 +27,14 @@ export default function MetricsTable({
|
|||||||
limit,
|
limit,
|
||||||
renderLabel,
|
renderLabel,
|
||||||
onDataLoad = () => {},
|
onDataLoad = () => {},
|
||||||
onExpand = () => {},
|
|
||||||
}) {
|
}) {
|
||||||
const [dateRange] = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, modified } = dateRange;
|
const { startDate, endDate, modified } = dateRange;
|
||||||
|
const {
|
||||||
|
resolve,
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
const { data } = useFetch(
|
const { data } = useFetch(
|
||||||
`/api/website/${websiteId}/rankings`,
|
`/api/website/${websiteId}/rankings`,
|
||||||
{
|
{
|
||||||
@ -37,6 +42,7 @@ export default function MetricsTable({
|
|||||||
start_at: +startDate,
|
start_at: +startDate,
|
||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
domain: websiteDomain,
|
domain: websiteDomain,
|
||||||
|
url,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
{ onDataLoad, delay: 300, update: [modified] },
|
{ onDataLoad, delay: 300, update: [modified] },
|
||||||
@ -100,11 +106,15 @@ export default function MetricsTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{limit && (
|
{limit && (
|
||||||
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
|
<Link
|
||||||
<div>
|
icon={<Arrow />}
|
||||||
<FormattedMessage id="button.more" defaultMessage="More" />
|
href="/website/[...id]"
|
||||||
</div>
|
as={resolve({ view: type })}
|
||||||
</Button>
|
size="small"
|
||||||
|
iconRight
|
||||||
|
>
|
||||||
|
<FormattedMessage id="button.more" defaultMessage="More" />
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.container {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 460px;
|
min-height: 430px;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -72,8 +73,8 @@
|
|||||||
.percent {
|
.percent {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
color: #6e6e6e;
|
color: var(--gray600);
|
||||||
border-left: 1px solid var(--gray500);
|
border-left: 1px solid var(--gray600);
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import MetricsTable from './MetricsTable';
|
|||||||
import { osFilter } from 'lib/filters';
|
import { osFilter } from 'lib/filters';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default function OSTable({ websiteId, token, limit, onExpand }) {
|
export default function OSTable({ websiteId, token, limit }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
|
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
|
||||||
@ -13,7 +13,6 @@ export default function OSTable({ websiteId, token, limit, onExpand }) {
|
|||||||
token={token}
|
token={token}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
dataFilter={osFilter}
|
dataFilter={osFilter}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Link from 'next/link';
|
||||||
import ButtonGroup from 'components/common/ButtonGroup';
|
import ButtonGroup from 'components/common/ButtonGroup';
|
||||||
|
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||||
import { urlFilter } from 'lib/filters';
|
import { urlFilter } from 'lib/filters';
|
||||||
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import ButtonLayout from '../layout/ButtonLayout';
|
import styles from './PagesTable.module.css';
|
||||||
|
|
||||||
export default function PagesTable({ websiteId, token, websiteDomain, limit, onExpand }) {
|
export default function PagesTable({ websiteId, token, websiteDomain, limit, showFilters }) {
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
|
const {
|
||||||
|
resolve,
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
@ -17,9 +25,24 @@ export default function PagesTable({ websiteId, token, websiteDomain, limit, onE
|
|||||||
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
|
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderLink = ({ x }) => {
|
||||||
|
return (
|
||||||
|
<Link href={resolve({ url: x })} replace={true}>
|
||||||
|
<a
|
||||||
|
className={classNames({
|
||||||
|
[styles.inactive]: url && x !== url,
|
||||||
|
[styles.active]: x === url,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{decodeURI(x)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!limit && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
||||||
type="url"
|
type="url"
|
||||||
@ -29,8 +52,7 @@ export default function PagesTable({ websiteId, token, websiteDomain, limit, onE
|
|||||||
limit={limit}
|
limit={limit}
|
||||||
dataFilter={urlFilter}
|
dataFilter={urlFilter}
|
||||||
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
|
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
|
||||||
renderLabel={({ x }) => decodeURI(x)}
|
renderLabel={renderLink}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
8
components/metrics/PagesTable.module.css
Normal file
8
components/metrics/PagesTable.module.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
body .inactive {
|
||||||
|
color: var(--gray500);
|
||||||
|
}
|
||||||
|
|
||||||
|
body .active {
|
||||||
|
color: var(--gray900);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
@ -6,13 +6,7 @@ import ButtonGroup from 'components/common/ButtonGroup';
|
|||||||
import { FILTER_DOMAIN_ONLY, FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
import { FILTER_DOMAIN_ONLY, FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
||||||
import ButtonLayout from '../layout/ButtonLayout';
|
import ButtonLayout from '../layout/ButtonLayout';
|
||||||
|
|
||||||
export default function ReferrersTable({
|
export default function ReferrersTable({ websiteId, websiteDomain, token, limit, showFilters }) {
|
||||||
websiteId,
|
|
||||||
websiteDomain,
|
|
||||||
token,
|
|
||||||
limit,
|
|
||||||
onExpand = () => {},
|
|
||||||
}) {
|
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
@ -39,7 +33,7 @@ export default function ReferrersTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!limit && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
||||||
type="referrer"
|
type="referrer"
|
||||||
@ -54,7 +48,6 @@ export default function ReferrersTable({
|
|||||||
domainOnly: filter === FILTER_DOMAIN_ONLY,
|
domainOnly: filter === FILTER_DOMAIN_ONLY,
|
||||||
raw: filter === FILTER_RAW,
|
raw: filter === FILTER_RAW,
|
||||||
}}
|
}}
|
||||||
onExpand={onExpand}
|
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -5,10 +5,13 @@ import MetricsBar from './MetricsBar';
|
|||||||
import WebsiteHeader from './WebsiteHeader';
|
import WebsiteHeader from './WebsiteHeader';
|
||||||
import DateFilter from 'components/common/DateFilter';
|
import DateFilter from 'components/common/DateFilter';
|
||||||
import StickyHeader from 'components/helpers/StickyHeader';
|
import StickyHeader from 'components/helpers/StickyHeader';
|
||||||
|
import Button from 'components/common/Button';
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
import useTimezone from 'hooks/useTimezone';
|
import useTimezone from 'hooks/useTimezone';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import { getDateArray, getDateLength } from 'lib/date';
|
import { getDateArray, getDateLength } from 'lib/date';
|
||||||
|
import Times from 'assets/times.svg';
|
||||||
import styles from './WebsiteChart.module.css';
|
import styles from './WebsiteChart.module.css';
|
||||||
|
|
||||||
export default function WebsiteChart({
|
export default function WebsiteChart({
|
||||||
@ -22,6 +25,11 @@ export default function WebsiteChart({
|
|||||||
const [dateRange, setDateRange] = useDateRange(websiteId);
|
const [dateRange, setDateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, unit, value, modified } = dateRange;
|
const { startDate, endDate, unit, value, modified } = dateRange;
|
||||||
const [timezone] = useTimezone();
|
const [timezone] = useTimezone();
|
||||||
|
const {
|
||||||
|
router,
|
||||||
|
resolve,
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
const { data, loading } = useFetch(
|
const { data, loading } = useFetch(
|
||||||
`/api/website/${websiteId}/pageviews`,
|
`/api/website/${websiteId}/pageviews`,
|
||||||
@ -30,6 +38,7 @@ export default function WebsiteChart({
|
|||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
unit,
|
unit,
|
||||||
tz: timezone,
|
tz: timezone,
|
||||||
|
url,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
{ onDataLoad, update: [modified] },
|
{ onDataLoad, update: [modified] },
|
||||||
@ -45,6 +54,10 @@ export default function WebsiteChart({
|
|||||||
return [[], []];
|
return [[], []];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
function handleCloseFilter() {
|
||||||
|
router.push(resolve({ url: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} token={token} title={title} showLink={showLink} />
|
<WebsiteHeader websiteId={websiteId} token={token} title={title} showLink={showLink} />
|
||||||
@ -54,6 +67,7 @@ export default function WebsiteChart({
|
|||||||
stickyClassName={styles.sticky}
|
stickyClassName={styles.sticky}
|
||||||
enabled={stickyHeader}
|
enabled={stickyHeader}
|
||||||
>
|
>
|
||||||
|
{url && <PageFilter url={url} onClick={handleCloseFilter} />}
|
||||||
<div className="col-12 col-lg-9">
|
<div className="col-12 col-lg-9">
|
||||||
<MetricsBar websiteId={websiteId} token={token} />
|
<MetricsBar websiteId={websiteId} token={token} />
|
||||||
</div>
|
</div>
|
||||||
@ -81,3 +95,13 @@ export default function WebsiteChart({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PageFilter = ({ url, onClick }) => {
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.url, 'col-12')}>
|
||||||
|
<Button icon={<Times />} onClick={onClick} variant="action" iconRight>
|
||||||
|
{url}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -36,6 +36,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
@media only screen and (max-width: 992px) {
|
||||||
.filter {
|
.filter {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -4,7 +4,6 @@ import Link from 'components/common/Link';
|
|||||||
import PageHeader from 'components/layout/PageHeader';
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
import RefreshButton from 'components/common/RefreshButton';
|
import RefreshButton from 'components/common/RefreshButton';
|
||||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||||
import Icon from 'components/common/Icon';
|
|
||||||
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';
|
||||||
@ -21,9 +20,11 @@ export default function WebsiteHeader({ websiteId, token, title, showLink = fals
|
|||||||
href="/website/[...id]"
|
href="/website/[...id]"
|
||||||
as={`/website/${websiteId}/${title}`}
|
as={`/website/${websiteId}/${title}`}
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
icon={<Arrow />}
|
||||||
|
size="small"
|
||||||
|
iconRight
|
||||||
>
|
>
|
||||||
<FormattedMessage id="button.view-details" defaultMessage="View details" />
|
<FormattedMessage id="button.view-details" defaultMessage="View details" />
|
||||||
<Icon icon={<Arrow />} size="small" />
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</ButtonLayout>
|
</ButtonLayout>
|
||||||
|
@ -5,14 +5,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
font-size: var(--font-size-small);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link svg {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
.active {
|
.active {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -42,14 +42,10 @@ export default function AccountSettings() {
|
|||||||
row.username !== 'admin' ? (
|
row.username !== 'admin' ? (
|
||||||
<ButtonLayout align="right">
|
<ButtonLayout align="right">
|
||||||
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
|
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
|
||||||
<div>
|
<FormattedMessage id="button.edit" defaultMessage="Edit" />
|
||||||
<FormattedMessage id="button.edit" defaultMessage="Edit" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
|
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
|
||||||
<div>
|
<FormattedMessage id="button.delete" defaultMessage="Delete" />
|
||||||
<FormattedMessage id="button.delete" defaultMessage="Delete" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonLayout>
|
</ButtonLayout>
|
||||||
) : null;
|
) : null;
|
||||||
@ -102,9 +98,7 @@ export default function AccountSettings() {
|
|||||||
<FormattedMessage id="label.accounts" defaultMessage="Accounts" />
|
<FormattedMessage id="label.accounts" defaultMessage="Accounts" />
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus />} size="small" onClick={() => setAddAccount(true)}>
|
<Button icon={<Plus />} size="small" onClick={() => setAddAccount(true)}>
|
||||||
<div>
|
<FormattedMessage id="button.add-account" defaultMessage="Add account" />
|
||||||
<FormattedMessage id="button.add-account" defaultMessage="Add account" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Table columns={columns} rows={data} />
|
<Table columns={columns} rows={data} />
|
||||||
|
@ -29,9 +29,7 @@ export default function ProfileSettings() {
|
|||||||
<FormattedMessage id="label.profile" defaultMessage="Profile" />
|
<FormattedMessage id="label.profile" defaultMessage="Profile" />
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Dots />} size="small" onClick={() => setChangePassword(true)}>
|
<Button icon={<Dots />} size="small" onClick={() => setChangePassword(true)}>
|
||||||
<div>
|
<FormattedMessage id="button.change-password" defaultMessage="Change password" />
|
||||||
<FormattedMessage id="button.change-password" defaultMessage="Change password" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<dl className={styles.list}>
|
<dl className={styles.list}>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTransition, animated } from 'react-spring';
|
import { useTransition, animated } from 'react-spring';
|
||||||
import Button from 'components/common/Button';
|
|
||||||
import useTheme from 'hooks/useTheme';
|
import useTheme from 'hooks/useTheme';
|
||||||
import Sun from 'assets/sun.svg';
|
import Sun from 'assets/sun.svg';
|
||||||
import Moon from 'assets/moon.svg';
|
import Moon from 'assets/moon.svg';
|
||||||
@ -27,7 +26,7 @@ export default function ThemeButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button className={styles.button} variant="light" onClick={handleClick}>
|
<div className={styles.button} onClick={handleClick}>
|
||||||
{transitions.map(({ item, key, props }) =>
|
{transitions.map(({ item, key, props }) =>
|
||||||
item === 'light' ? (
|
item === 'light' ? (
|
||||||
<animated.div key={key} style={props}>
|
<animated.div key={key} style={props}>
|
||||||
@ -39,6 +38,6 @@ export default function ThemeButton() {
|
|||||||
</animated.div>
|
</animated.div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
.button {
|
.button {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button svg {
|
.button svg {
|
||||||
|
@ -52,14 +52,10 @@ export default function WebsiteSettings() {
|
|||||||
onClick={() => setShowCode(row)}
|
onClick={() => setShowCode(row)}
|
||||||
/>
|
/>
|
||||||
<Button icon={<Pen />} size="small" onClick={() => setEditWebsite(row)}>
|
<Button icon={<Pen />} size="small" onClick={() => setEditWebsite(row)}>
|
||||||
<div>
|
<FormattedMessage id="button.edit" defaultMessage="Edit" />
|
||||||
<FormattedMessage id="button.edit" defaultMessage="Edit" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Trash />} size="small" onClick={() => setDeleteWebsite(row)}>
|
<Button icon={<Trash />} size="small" onClick={() => setDeleteWebsite(row)}>
|
||||||
<div>
|
<FormattedMessage id="button.delete" defaultMessage="Delete" />
|
||||||
<FormattedMessage id="button.delete" defaultMessage="Delete" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonLayout>
|
</ButtonLayout>
|
||||||
);
|
);
|
||||||
@ -117,9 +113,7 @@ export default function WebsiteSettings() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button icon={<Plus />} size="medium" onClick={() => setAddWebsite(true)}>
|
<Button icon={<Plus />} size="medium" onClick={() => setAddWebsite(true)}>
|
||||||
<div>
|
<FormattedMessage id="button.add-website" defaultMessage="Add website" />
|
||||||
<FormattedMessage id="button.add-website" defaultMessage="Add website" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
);
|
);
|
||||||
@ -131,9 +125,7 @@ export default function WebsiteSettings() {
|
|||||||
<FormattedMessage id="label.websites" defaultMessage="Websites" />
|
<FormattedMessage id="label.websites" defaultMessage="Websites" />
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus />} size="small" onClick={() => setAddWebsite(true)}>
|
<Button icon={<Plus />} size="small" onClick={() => setAddWebsite(true)}>
|
||||||
<div>
|
<FormattedMessage id="button.add-website" defaultMessage="Add website" />
|
||||||
<FormattedMessage id="button.add-website" defaultMessage="Add website" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Table columns={columns} rows={data} empty={empty} />
|
<Table columns={columns} rows={data} empty={empty} />
|
||||||
|
32
hooks/usePageQuery.js
Normal file
32
hooks/usePageQuery.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { getQueryString } from '../lib/url';
|
||||||
|
|
||||||
|
export default function usePageQuery() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { pathname, search } = location;
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
if (!search) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = search.substring(1).split('&');
|
||||||
|
|
||||||
|
return params.reduce((obj, item) => {
|
||||||
|
const [key, value] = item.split('=');
|
||||||
|
|
||||||
|
obj[key] = decodeURIComponent(value);
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
function resolve(params) {
|
||||||
|
const search = getQueryString({ ...query, ...params });
|
||||||
|
|
||||||
|
return `${pathname}${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pathname, query, resolve, router };
|
||||||
|
}
|
95
lang/fo-FO.json
Normal file
95
lang/fo-FO.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"button.add-account": "Ger brúkara",
|
||||||
|
"button.add-website": "Legg heimasíðu til",
|
||||||
|
"button.back": "Aftur",
|
||||||
|
"button.cancel": "Strika",
|
||||||
|
"button.change-password": "Broyt loyniorð",
|
||||||
|
"button.copy-to-clipboard": "Kopier til clipboard",
|
||||||
|
"button.date-range": "Vel dato",
|
||||||
|
"button.delete": "Sletta",
|
||||||
|
"button.edit": "Ger broyting",
|
||||||
|
"button.login": "Rita inn",
|
||||||
|
"button.more": "Meira",
|
||||||
|
"button.refresh": "Endurskapa",
|
||||||
|
"button.reset": "Nulstilla",
|
||||||
|
"button.save": "Goym",
|
||||||
|
"button.single-day": "Einkultur dagur",
|
||||||
|
"button.view-details": "Vís upplýsingar",
|
||||||
|
"label.accounts": "Brúkarar",
|
||||||
|
"label.administrator": "Administrator",
|
||||||
|
"label.confirm-password": "Vátta loyniorð",
|
||||||
|
"label.current-password": "Núverandi loyniorð",
|
||||||
|
"label.custom-range": "Tillaga spenni",
|
||||||
|
"label.dashboard": "Yvirlitsskíggi",
|
||||||
|
"label.default-date-range": "Standard dato",
|
||||||
|
"label.domain": "Økisnavn",
|
||||||
|
"label.enable-share-url": "Virkja deili leinki",
|
||||||
|
"label.invalid": "Ógilda",
|
||||||
|
"label.invalid-domain": "Ógilt økisnavn",
|
||||||
|
"label.last-days": "Seinastu {x} dagarnar",
|
||||||
|
"label.last-hours": "Seinastu {x} tímanar",
|
||||||
|
"label.logged-in-as": "Ritaður inn sum {username}",
|
||||||
|
"label.logout": "Rita út",
|
||||||
|
"label.name": "Navn",
|
||||||
|
"label.new-password": "Nýtt loyniorð",
|
||||||
|
"label.password": "Loyniorð",
|
||||||
|
"label.passwords-dont-match": "Loyniorðini eru ikki eins",
|
||||||
|
"label.profile": "Brúkari",
|
||||||
|
"label.required": "Krav",
|
||||||
|
"label.settings": "Stillingar",
|
||||||
|
"label.this-month": "Hendan mánan",
|
||||||
|
"label.this-week": "Hesa vikuna",
|
||||||
|
"label.this-year": "Hetta árið",
|
||||||
|
"label.timezone": "Tíðarsona",
|
||||||
|
"label.today": "Í dag",
|
||||||
|
"label.unknown": "Ókent",
|
||||||
|
"label.username": "Brúkaranavn",
|
||||||
|
"label.websites": "Heimasíður",
|
||||||
|
"message.active-users": "{x} í løtuni {x, plural, one {vitjandi} other { vitjandi }}",
|
||||||
|
"message.confirm-delete": "Ert tú sikkur at tú ynskir at sletta {target}?",
|
||||||
|
"message.copied": "Kopiera!",
|
||||||
|
"message.delete-warning": "Øll data ið er knýtt at verður eisini sletta.",
|
||||||
|
"message.failure": "Okkurt bleiv gali.",
|
||||||
|
"message.get-share-url": "Fá leinku sum tú kanst deila",
|
||||||
|
"message.get-tracking-code": "Fá sporings kotu",
|
||||||
|
"message.go-to-settings": "Far til stillingar",
|
||||||
|
"message.incorrect-username-password": "Skeivt brúkaranavn/loyniorð.",
|
||||||
|
"message.no-data-available": "Einki data tøk.",
|
||||||
|
"message.no-websites-configured": "Tú hevur ongar heimasíður stillaða til.",
|
||||||
|
"message.page-not-found": "Síðan bleiv ikki funnin.",
|
||||||
|
"message.powered-by": "Powered by {name}",
|
||||||
|
"message.save-success": "Goymt.",
|
||||||
|
"message.share-url": "Hetta er tann almenna leinkan av {target}.",
|
||||||
|
"message.track-stats": "Fyri at spora hagtøl fyri {target}, koyr kotuna í {head} partin á tínari heimasíðu.",
|
||||||
|
"message.type-delete": "Skriva {delete} í feltið fyri at vátta",
|
||||||
|
"metrics.actions": "Gerðir",
|
||||||
|
"metrics.average-visit-time": "Miðal vitjurnartíð ",
|
||||||
|
"metrics.bounce-rate": "Bounce prosenttal",
|
||||||
|
"metrics.browsers": "Kagar",
|
||||||
|
"metrics.countries": "Lond",
|
||||||
|
"metrics.device.desktop": "Borðtelda",
|
||||||
|
"metrics.device.laptop": "Fartelda",
|
||||||
|
"metrics.device.mobile": "Telefon",
|
||||||
|
"metrics.device.tablet": "Teldil",
|
||||||
|
"metrics.devices": "Tóleindir",
|
||||||
|
"metrics.events": "Hendingar/tiltøk",
|
||||||
|
"metrics.filter.combined": "Samansett",
|
||||||
|
"metrics.filter.domain-only": "Bara økisnavn",
|
||||||
|
"metrics.filter.raw": "Óviðgjørt",
|
||||||
|
"metrics.operating-systems": "Stýrikervir",
|
||||||
|
"metrics.page-views": "Opnaðar síðir",
|
||||||
|
"metrics.pages": "Síðir",
|
||||||
|
"metrics.referrers": "Framsendingar",
|
||||||
|
"metrics.unique-visitors": "Einsýna vitjanir",
|
||||||
|
"metrics.views": "Vitjanir",
|
||||||
|
"metrics.visitors": "Vitjandi",
|
||||||
|
"title.add-account": "Ger brúkara",
|
||||||
|
"title.add-website": "Legg heimasíðu avtrat",
|
||||||
|
"title.change-password": "Skift loyniorð",
|
||||||
|
"title.delete-account": "Sletta brúkara",
|
||||||
|
"title.delete-website": "Sletta heimasíðu",
|
||||||
|
"title.edit-account": "Broyt brúkara",
|
||||||
|
"title.edit-website": "Broyt heimasíðu",
|
||||||
|
"title.share-url": "Deil leinku",
|
||||||
|
"title.tracking-code": "Spori kota"
|
||||||
|
}
|
@ -31,9 +31,3 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
|
||||||
export async function runQuery(query) {
|
|
||||||
return query.catch(e => {
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
@ -13,7 +13,7 @@ export const urlFilter = (data, { raw }) => {
|
|||||||
|
|
||||||
const cleanUrl = url => {
|
const cleanUrl = url => {
|
||||||
try {
|
try {
|
||||||
const { pathname, search } = new URL(url);
|
const { pathname, search } = new URL(url, location.origin);
|
||||||
|
|
||||||
if (search.startsWith('?/')) {
|
if (search.startsWith('?/')) {
|
||||||
return `${pathname}${search}`;
|
return `${pathname}${search}`;
|
||||||
@ -30,7 +30,7 @@ export const urlFilter = (data, { raw }) => {
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = cleanUrl(`http://x${x}`);
|
const url = cleanUrl(x);
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
if (!obj[url]) {
|
if (!obj[url]) {
|
||||||
|
@ -13,6 +13,7 @@ import mnMNMessages from 'lang-compiled/mn-MN.json';
|
|||||||
import daMessages from 'lang-compiled/da-DK.json';
|
import daMessages from 'lang-compiled/da-DK.json';
|
||||||
import svMessages from 'lang-compiled/sv-SE.json';
|
import svMessages from 'lang-compiled/sv-SE.json';
|
||||||
import grMessages from 'lang-compiled/el-GR.json';
|
import grMessages from 'lang-compiled/el-GR.json';
|
||||||
|
import foMessages from 'lang-compiled/fo-FO.json';
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
'en-US': enMessages,
|
'en-US': enMessages,
|
||||||
@ -28,6 +29,7 @@ export const messages = {
|
|||||||
'da-DK': daMessages,
|
'da-DK': daMessages,
|
||||||
'sv-SE': svMessages,
|
'sv-SE': svMessages,
|
||||||
'el-GR': grMessages,
|
'el-GR': grMessages,
|
||||||
|
'fo-FO': foMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dateLocales = {
|
export const dateLocales = {
|
||||||
@ -44,6 +46,7 @@ export const dateLocales = {
|
|||||||
'fr-FR': fr,
|
'fr-FR': fr,
|
||||||
'mn-MN': enUS,
|
'mn-MN': enUS,
|
||||||
'el-GR': el,
|
'el-GR': el,
|
||||||
|
'fo-FO': da,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const menuOptions = [
|
export const menuOptions = [
|
||||||
@ -52,6 +55,7 @@ export const menuOptions = [
|
|||||||
{ label: 'Dansk', value: 'da-DK', display: 'da' },
|
{ label: 'Dansk', value: 'da-DK', display: 'da' },
|
||||||
{ label: 'Deutsch', value: 'de-DE', display: 'de' },
|
{ label: 'Deutsch', value: 'de-DE', display: 'de' },
|
||||||
{ label: 'Español', value: 'es-MX', display: 'es' },
|
{ label: 'Español', value: 'es-MX', display: 'es' },
|
||||||
|
{ label: 'Føroyskt', value: 'fo-FO', display: 'fo' },
|
||||||
{ label: 'Français', value: 'fr-FR', display: 'fr' },
|
{ label: 'Français', value: 'fr-FR', display: 'fr' },
|
||||||
{ label: 'Ελληνικά', value: 'el-GR', display: 'el' },
|
{ label: 'Ελληνικά', value: 'el-GR', display: 'el' },
|
||||||
{ label: '日本語', value: 'ja-JP', display: 'ja' },
|
{ label: '日本語', value: 'ja-JP', display: 'ja' },
|
||||||
@ -59,7 +63,7 @@ export const menuOptions = [
|
|||||||
{ label: 'Nederlands', value: 'nl-NL', display: 'nl' },
|
{ label: 'Nederlands', value: 'nl-NL', display: 'nl' },
|
||||||
{ label: 'Русский', value: 'ru-RU', display: 'ru' },
|
{ label: 'Русский', value: 'ru-RU', display: 'ru' },
|
||||||
{ label: 'Svenska', value: 'sv-SE', display: 'sv' },
|
{ label: 'Svenska', value: 'sv-SE', display: 'sv' },
|
||||||
{ label: 'Turkish', value: 'tr-TR', display: 'tr' },
|
{ label: 'Türkçe', value: 'tr-TR', display: 'tr' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function dateFormat(date, str, locale) {
|
export function dateFormat(date, str, locale) {
|
||||||
|
335
lib/queries.js
335
lib/queries.js
@ -1,5 +1,5 @@
|
|||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import prisma, { runQuery } from 'lib/db';
|
import prisma 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';
|
import { MYSQL, POSTGRESQL, MYSQL_DATE_FORMATS, POSTGRESQL_DATE_FORMATS } from 'lib/constants';
|
||||||
|
|
||||||
@ -15,7 +15,27 @@ export function getDatabase() {
|
|||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDateQuery(db, field, unit, timezone) {
|
export async function runQuery(query) {
|
||||||
|
return query.catch(e => {
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rawQuery(query, params) {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
if (db !== POSTGRESQL && db !== MYSQL) {
|
||||||
|
return Promise.reject(new Error('Unknown database.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
|
||||||
|
|
||||||
|
return prisma.$queryRaw.apply(prisma, [sql, ...params]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateQuery(field, unit, timezone) {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
if (db === POSTGRESQL) {
|
||||||
if (timezone) {
|
if (timezone) {
|
||||||
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
|
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
|
||||||
@ -34,6 +54,18 @@ export function getDateQuery(db, field, unit, timezone) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimestampInterval(field) {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
if (db === POSTGRESQL) {
|
||||||
|
return `floor(extract(epoch from max(${field}) - min(${field})))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db === MYSQL) {
|
||||||
|
return `floor(unix_timestamp(max(${field})) - unix_timestamp(min(${field})))`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWebsiteById(website_id) {
|
export async function getWebsiteById(website_id) {
|
||||||
return runQuery(
|
return runQuery(
|
||||||
prisma.website.findOne({
|
prisma.website.findOne({
|
||||||
@ -253,62 +285,35 @@ export async function createAccount(data) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMetrics(website_id, start_at, end_at) {
|
export function getMetrics(website_id, start_at, end_at, url) {
|
||||||
const db = getDatabase();
|
const params = [website_id, start_at, end_at];
|
||||||
|
let urlFilter = '';
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
if (url) {
|
||||||
return runQuery(
|
urlFilter = `and url=$${params.length + 1}`;
|
||||||
prisma.$queryRaw(
|
params.push(decodeURIComponent(url));
|
||||||
`
|
}
|
||||||
|
|
||||||
|
return rawQuery(
|
||||||
|
`
|
||||||
select sum(t.c) as "pageviews",
|
select sum(t.c) as "pageviews",
|
||||||
count(distinct t.session_id) as "uniques",
|
count(distinct t.session_id) as "uniques",
|
||||||
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
|
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
|
||||||
sum(t.time) as "totaltime"
|
sum(t.time) as "totaltime"
|
||||||
from (
|
from (
|
||||||
select session_id,
|
select session_id,
|
||||||
${getDateQuery(db, 'created_at', 'hour')},
|
${getDateQuery('created_at', 'hour')},
|
||||||
count(*) c,
|
count(*) c,
|
||||||
floor(extract(epoch from max(created_at) - min(created_at))) as "time"
|
${getTimestampInterval('created_at')} as "time"
|
||||||
from pageview
|
from pageview
|
||||||
where website_id=$1
|
where website_id=$1
|
||||||
and created_at between $2 and $3
|
and created_at between $2 and $3
|
||||||
|
${urlFilter}
|
||||||
group by 1, 2
|
group by 1, 2
|
||||||
) t
|
) t
|
||||||
`,
|
`,
|
||||||
website_id,
|
params,
|
||||||
start_at,
|
);
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db === MYSQL) {
|
|
||||||
return runQuery(
|
|
||||||
prisma.$queryRaw(
|
|
||||||
`
|
|
||||||
select sum(t.c) as "pageviews",
|
|
||||||
count(distinct t.session_id) as "uniques",
|
|
||||||
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
|
|
||||||
sum(t.time) as "totaltime"
|
|
||||||
from (
|
|
||||||
select session_id,
|
|
||||||
${getDateQuery(db, 'created_at', 'hour')},
|
|
||||||
count(*) c,
|
|
||||||
floor(unix_timestamp(max(created_at)) - unix_timestamp(min(created_at))) as "time"
|
|
||||||
from pageview
|
|
||||||
where website_id=?
|
|
||||||
and created_at between ? and ?
|
|
||||||
group by 1, 2
|
|
||||||
) t
|
|
||||||
`,
|
|
||||||
website_id,
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error('Unknown database.'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPageviews(
|
export function getPageviews(
|
||||||
@ -318,177 +323,125 @@ export function getPageviews(
|
|||||||
timezone = 'utc',
|
timezone = 'utc',
|
||||||
unit = 'day',
|
unit = 'day',
|
||||||
count = '*',
|
count = '*',
|
||||||
|
url,
|
||||||
) {
|
) {
|
||||||
const db = getDatabase();
|
const params = [website_id, start_at, end_at];
|
||||||
|
let urlFilter = '';
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
if (url) {
|
||||||
return runQuery(
|
urlFilter = `and url=$${params.length + 1}`;
|
||||||
prisma.$queryRaw(
|
params.push(decodeURIComponent(url));
|
||||||
`
|
|
||||||
select ${getDateQuery(db, 'created_at', unit, timezone)} t,
|
|
||||||
count(${count}) y
|
|
||||||
from pageview
|
|
||||||
where website_id=$1
|
|
||||||
and created_at between $2 and $3
|
|
||||||
group by 1
|
|
||||||
order by 1
|
|
||||||
`,
|
|
||||||
website_id,
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db === MYSQL) {
|
return rawQuery(
|
||||||
return runQuery(
|
`
|
||||||
prisma.$queryRaw(
|
select ${getDateQuery('created_at', unit, timezone)} t,
|
||||||
`
|
count(${count}) y
|
||||||
select ${getDateQuery(db, 'created_at', unit, timezone)} t,
|
from pageview
|
||||||
count(${count}) y
|
where website_id=$1
|
||||||
from pageview
|
and created_at between $2 and $3
|
||||||
where website_id=?
|
${urlFilter}
|
||||||
and created_at between ? and ?
|
group by 1
|
||||||
group by 1
|
order by 1
|
||||||
order by 1
|
`,
|
||||||
`,
|
params,
|
||||||
website_id,
|
);
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error('Unknown database.'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRankings(website_id, start_at, end_at, type, table, domain) {
|
export function getSessionMetrics(website_id, start_at, end_at, field, url) {
|
||||||
const db = getDatabase();
|
const params = [website_id, start_at, end_at];
|
||||||
|
let urlFilter = '';
|
||||||
|
|
||||||
const filter = domain ? `and ${type} not like '%${domain}%'` : '';
|
if (url) {
|
||||||
|
urlFilter = `and url=$${params.length + 1}`;
|
||||||
|
params.push(decodeURIComponent(url));
|
||||||
|
}
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
return rawQuery(
|
||||||
return runQuery(
|
`
|
||||||
prisma.$queryRaw(
|
select ${field} x, count(*) y
|
||||||
`
|
from session
|
||||||
select distinct ${type} x, count(*) y
|
where session_id in (
|
||||||
from ${table}
|
select session_id
|
||||||
|
from pageview
|
||||||
where website_id=$1
|
where website_id=$1
|
||||||
and created_at between $2 and $3
|
and created_at between $2 and $3
|
||||||
${filter}
|
${urlFilter}
|
||||||
group by 1
|
)
|
||||||
order by 2 desc
|
group by 1
|
||||||
`,
|
order by 2 desc
|
||||||
website_id,
|
`,
|
||||||
start_at,
|
params,
|
||||||
end_at,
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
|
export function getPageviewMetrics(website_id, start_at, end_at, field, table, domain, url) {
|
||||||
|
const params = [website_id, start_at, end_at];
|
||||||
|
let domainFilter = '';
|
||||||
|
let urlFilter = '';
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
domainFilter = `and referrer not like $${params.length + 1}`;
|
||||||
|
params.push(`%${domain}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db === MYSQL) {
|
if (url) {
|
||||||
return runQuery(
|
urlFilter = `and url=$${params.length + 1}`;
|
||||||
prisma.$queryRaw(
|
params.push(decodeURIComponent(url));
|
||||||
`
|
|
||||||
select distinct ${type} x, count(*) y
|
|
||||||
from ${table}
|
|
||||||
where website_id=?
|
|
||||||
and created_at between ? and ?
|
|
||||||
${filter}
|
|
||||||
group by 1
|
|
||||||
order by 2 desc
|
|
||||||
`,
|
|
||||||
website_id,
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(new Error('Unknown database.'));
|
return rawQuery(
|
||||||
|
`
|
||||||
|
select ${field} x, count(*) y
|
||||||
|
from ${table}
|
||||||
|
where website_id=$1
|
||||||
|
and created_at between $2 and $3
|
||||||
|
${domainFilter}
|
||||||
|
${urlFilter}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveVisitors(website_id) {
|
export function getActiveVisitors(website_id) {
|
||||||
const db = getDatabase();
|
|
||||||
const date = subMinutes(new Date(), 5);
|
const date = subMinutes(new Date(), 5);
|
||||||
|
const params = [website_id, date];
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
return rawQuery(
|
||||||
return runQuery(
|
`
|
||||||
prisma.$queryRaw(
|
|
||||||
`
|
|
||||||
select count(distinct session_id) x
|
select count(distinct session_id) x
|
||||||
from pageview
|
from pageview
|
||||||
where website_id=$1
|
where website_id=$1
|
||||||
and created_at >= $2
|
and created_at >= $2
|
||||||
`,
|
`,
|
||||||
website_id,
|
params,
|
||||||
date,
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
|
export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = 'day', url) {
|
||||||
|
const params = [website_id, start_at, end_at];
|
||||||
|
let urlFilter = '';
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
urlFilter = `and url=$${params.length + 1}`;
|
||||||
|
params.push(decodeURIComponent(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db === MYSQL) {
|
return rawQuery(
|
||||||
return runQuery(
|
`
|
||||||
prisma.$queryRaw(
|
select
|
||||||
`
|
event_value x,
|
||||||
select count(distinct session_id) x
|
${getDateQuery('created_at', unit, timezone)} t,
|
||||||
from pageview
|
count(*) y
|
||||||
where website_id=?
|
from event
|
||||||
and created_at >= ?
|
where website_id=$1
|
||||||
|
and created_at between $2 and $3
|
||||||
|
${urlFilter}
|
||||||
|
group by 1, 2
|
||||||
|
order by 2
|
||||||
`,
|
`,
|
||||||
website_id,
|
params,
|
||||||
date,
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error('Unknown database.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = 'day') {
|
|
||||||
const db = getDatabase();
|
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
|
||||||
return runQuery(
|
|
||||||
prisma.$queryRaw(
|
|
||||||
`
|
|
||||||
select
|
|
||||||
event_value x,
|
|
||||||
${getDateQuery(db, 'created_at', unit, timezone)} t,
|
|
||||||
count(*) y
|
|
||||||
from event
|
|
||||||
where website_id=$1
|
|
||||||
and created_at between $2 and $3
|
|
||||||
group by 1, 2
|
|
||||||
order by 2
|
|
||||||
`,
|
|
||||||
website_id,
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db === MYSQL) {
|
|
||||||
return runQuery(
|
|
||||||
prisma.$queryRaw(
|
|
||||||
`
|
|
||||||
select
|
|
||||||
event_value x,
|
|
||||||
${getDateQuery(db, 'created_at', unit, timezone)} t,
|
|
||||||
count(*) y
|
|
||||||
from event
|
|
||||||
where website_id=?
|
|
||||||
and created_at between ? and ?
|
|
||||||
group by 1, 2
|
|
||||||
order by 2
|
|
||||||
`,
|
|
||||||
website_id,
|
|
||||||
start_at,
|
|
||||||
end_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error('Unknown database.'));
|
|
||||||
}
|
}
|
||||||
|
15
lib/url.js
15
lib/url.js
@ -13,3 +13,18 @@ export function getDomainName(str) {
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getQueryString(params) {
|
||||||
|
const map = Object.keys(params).reduce((arr, key) => {
|
||||||
|
if (params[key] !== undefined) {
|
||||||
|
return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (map.length) {
|
||||||
|
return `?${map.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
16
lib/web.js
16
lib/web.js
@ -1,3 +1,5 @@
|
|||||||
|
import { getQueryString } from './url';
|
||||||
|
|
||||||
export const apiRequest = (method, url, body) =>
|
export const apiRequest = (method, url, body) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method,
|
method,
|
||||||
@ -20,19 +22,9 @@ export const apiRequest = (method, url, body) =>
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseQuery = (url, params = {}) => {
|
export const get = (url, params) => apiRequest('get', `${url}${getQueryString(params)}`);
|
||||||
const query = Object.keys(params).reduce((values, key) => {
|
|
||||||
if (params[key] !== undefined) {
|
|
||||||
return values.concat(`${key}=${encodeURIComponent(params[key])}`);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}, []);
|
|
||||||
return query.length ? `${url}?${query.join('&')}` : url;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const get = (url, params) => apiRequest('get', parseQuery(url, params));
|
export const del = (url, params) => apiRequest('delete', `${url}${getQueryString(params)}`);
|
||||||
|
|
||||||
export const del = (url, params) => apiRequest('delete', parseQuery(url, params));
|
|
||||||
|
|
||||||
export const post = (url, params) => apiRequest('post', url, JSON.stringify(params));
|
export const post = (url, params) => apiRequest('post', url, JSON.stringify(params));
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "0.50.0",
|
"version": "0.54.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",
|
||||||
|
@ -11,7 +11,7 @@ export default async (req, res) => {
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, start_at, end_at, unit, tz } = req.query;
|
const { id, start_at, end_at, unit, tz, url } = req.query;
|
||||||
|
|
||||||
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
|
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
|
||||||
return badRequest(res);
|
return badRequest(res);
|
||||||
@ -21,7 +21,7 @@ export default async (req, res) => {
|
|||||||
const startDate = new Date(+start_at);
|
const startDate = new Date(+start_at);
|
||||||
const endDate = new Date(+end_at);
|
const endDate = new Date(+end_at);
|
||||||
|
|
||||||
const events = await getEvents(websiteId, startDate, endDate, tz, unit);
|
const events = await getEvents(websiteId, startDate, endDate, tz, unit, url);
|
||||||
|
|
||||||
return ok(res, events);
|
return ok(res, events);
|
||||||
}
|
}
|
||||||
|
@ -8,13 +8,13 @@ export default async (req, res) => {
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, start_at, end_at } = req.query;
|
const { id, start_at, end_at, url } = req.query;
|
||||||
|
|
||||||
const websiteId = +id;
|
const websiteId = +id;
|
||||||
const startDate = new Date(+start_at);
|
const startDate = new Date(+start_at);
|
||||||
const endDate = new Date(+end_at);
|
const endDate = new Date(+end_at);
|
||||||
|
|
||||||
const metrics = await getMetrics(websiteId, startDate, endDate);
|
const metrics = await getMetrics(websiteId, startDate, endDate, url);
|
||||||
|
|
||||||
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
|
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
|
||||||
obj[key] = Number(metrics[0][key]) || 0;
|
obj[key] = Number(metrics[0][key]) || 0;
|
||||||
|
@ -11,7 +11,7 @@ export default async (req, res) => {
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, start_at, end_at, unit, tz } = req.query;
|
const { id, start_at, end_at, unit, tz, url } = req.query;
|
||||||
|
|
||||||
const websiteId = +id;
|
const websiteId = +id;
|
||||||
const startDate = new Date(+start_at);
|
const startDate = new Date(+start_at);
|
||||||
@ -22,8 +22,8 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [pageviews, uniques] = await Promise.all([
|
const [pageviews, uniques] = await Promise.all([
|
||||||
getPageviews(websiteId, startDate, endDate, tz, unit, '*'),
|
getPageviews(websiteId, startDate, endDate, tz, unit, '*', url),
|
||||||
getPageviews(websiteId, startDate, endDate, tz, unit, 'distinct session_id'),
|
getPageviews(websiteId, startDate, endDate, tz, unit, 'distinct session_id', url),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return ok(res, { pageviews, uniques });
|
return ok(res, { pageviews, uniques });
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getRankings } from 'lib/queries';
|
import { getPageviewMetrics, getSessionMetrics } from 'lib/queries';
|
||||||
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
|
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
|
||||||
import { DOMAIN_REGEX } from 'lib/constants';
|
import { DOMAIN_REGEX } from 'lib/constants';
|
||||||
import { allowQuery } from 'lib/auth';
|
import { allowQuery } from 'lib/auth';
|
||||||
@ -31,32 +31,35 @@ export default async (req, res) => {
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, type, start_at, end_at, domain } = req.query;
|
const { id, type, start_at, end_at, domain, url } = req.query;
|
||||||
|
|
||||||
|
if (domain && !DOMAIN_REGEX.test(domain)) {
|
||||||
|
return badRequest(res);
|
||||||
|
}
|
||||||
|
|
||||||
const websiteId = +id;
|
const websiteId = +id;
|
||||||
const startDate = new Date(+start_at);
|
const startDate = new Date(+start_at);
|
||||||
const endDate = new Date(+end_at);
|
const endDate = new Date(+end_at);
|
||||||
|
|
||||||
if (
|
if (sessionColumns.includes(type)) {
|
||||||
type !== 'event' &&
|
const data = await getSessionMetrics(websiteId, startDate, endDate, type, url);
|
||||||
!sessionColumns.includes(type) &&
|
|
||||||
!pageviewColumns.includes(type) &&
|
return ok(res, data);
|
||||||
domain &&
|
|
||||||
DOMAIN_REGEX.test(domain)
|
|
||||||
) {
|
|
||||||
return badRequest(res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rankings = await getRankings(
|
if (type === 'event' || pageviewColumns.includes(type)) {
|
||||||
websiteId,
|
const data = await getPageviewMetrics(
|
||||||
startDate,
|
websiteId,
|
||||||
endDate,
|
startDate,
|
||||||
getColumn(type),
|
endDate,
|
||||||
getTable(type),
|
getColumn(type),
|
||||||
domain,
|
getTable(type),
|
||||||
);
|
domain,
|
||||||
|
type !== 'url' ? url : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return ok(res, rankings);
|
return ok(res, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
return methodNotAllowed(res);
|
||||||
|
Loading…
Reference in New Issue
Block a user