URL filter functionality.

This commit is contained in:
Mike Cao 2020-09-25 22:31:18 -07:00
parent 6bc371352c
commit 4fded49b03
27 changed files with 251 additions and 117 deletions

View File

@ -1,6 +1,5 @@
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';
@ -19,6 +18,7 @@ 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 Loading from 'components/common/Loading';
import usePageQuery from '../hooks/usePageQuery';
const views = { const views = {
url: PagesTable, url: PagesTable,
@ -31,18 +31,16 @@ 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 }, pathname,
basePath, resolve,
asPath, router,
} = router; query: { view },
} = usePageQuery();
const path = `${basePath}/${asPath.split('/')[1]}/${id.join('/')}`;
const BackButton = () => ( const BackButton = () => (
<Button <Button
@ -50,11 +48,9 @@ export default function WebsiteDetails({ websiteId, token }) {
className={styles.backButton} className={styles.backButton}
icon={<Arrow />} icon={<Arrow />}
size="xsmall" size="xsmall"
onClick={() => router.push(path)} onClick={() => router.push(pathname)}
> >
<div>
<FormattedMessage id="button.back" defaultMessage="Back" /> <FormattedMessage id="button.back" defaultMessage="Back" />
</div>
</Button> </Button>
); );
@ -64,31 +60,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' }),
}, },
]; ];
@ -109,7 +105,7 @@ export default function WebsiteDetails({ websiteId, token }) {
} }
function handleExpand(value) { function handleExpand(value) {
router.push(`${path}?view=${value}`); router.push(resolve({ view: value }));
} }
if (!data) { if (!data) {
@ -179,7 +175,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>

View File

@ -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>
)} )}

View File

@ -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>{children}</div>}
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>} {tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
</button> </button>
); );

View File

@ -38,7 +38,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 +65,19 @@
background: inherit; background: inherit;
} }
.button .icon + div {
margin-left: 10px;
}
.button.iconRight .icon {
order: 1;
margin-left: 10px;
}
.button.iconRight .icon + div {
margin: 0;
}
.button:disabled { .button:disabled {
cursor: default; cursor: default;
color: var(--gray500); color: var(--gray500);

View File

@ -5,10 +5,6 @@
vertical-align: middle; vertical-align: middle;
} }
.icon + * {
margin-left: 10px;
}
.icon svg { .icon svg {
fill: currentColor; fill: currentColor;
} }

View File

@ -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] },

View File

@ -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;

View File

@ -12,6 +12,7 @@ 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 styles from './MetricsTable.module.css'; import styles from './MetricsTable.module.css';
import usePageQuery from '../../hooks/usePageQuery';
export default function MetricsTable({ export default function MetricsTable({
websiteId, websiteId,
@ -30,6 +31,10 @@ export default function MetricsTable({
}) { }) {
const [dateRange] = useDateRange(websiteId); const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange; const { startDate, endDate, modified } = dateRange;
const {
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] },
@ -101,9 +107,7 @@ export default function MetricsTable({
<div className={styles.footer}> <div className={styles.footer}>
{limit && ( {limit && (
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}> <Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
<div>
<FormattedMessage id="button.more" defaultMessage="More" /> <FormattedMessage id="button.more" defaultMessage="More" />
</div>
</Button> </Button>
)} )}
</div> </div>

View File

@ -1,13 +1,23 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
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';
export default function PagesTable({ websiteId, token, websiteDomain, limit, onExpand }) { export default function PagesTable({
websiteId,
token,
websiteDomain,
limit,
showFilters,
onExpand,
}) {
const [filter, setFilter] = useState(FILTER_COMBINED); const [filter, setFilter] = useState(FILTER_COMBINED);
const { resolve } = usePageQuery();
const buttons = [ const buttons = [
{ {
@ -17,9 +27,17 @@ 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>{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,7 +47,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} onExpand={onExpand}
/> />
</> </>

View File

@ -11,6 +11,7 @@ export default function ReferrersTable({
websiteDomain, websiteDomain,
token, token,
limit, limit,
showFilters,
onExpand = () => {}, onExpand = () => {},
}) { }) {
const [filter, setFilter] = useState(FILTER_COMBINED); const [filter, setFilter] = useState(FILTER_COMBINED);
@ -39,7 +40,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"

View File

@ -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={true}>
{url}
</Button>
</div>
);
};

View File

@ -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;

View File

@ -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} />

View File

@ -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}>

View File

@ -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>
); );
} }

View File

@ -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 {

View File

@ -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
View 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 };
}

View File

@ -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]) {

View File

@ -21,7 +21,7 @@ export async function runQuery(query) {
}); });
} }
export async function rawQuery(query, ...params) { export async function rawQuery(query, params) {
const db = getDatabase(); const db = getDatabase();
if (db !== POSTGRESQL && db !== MYSQL) { if (db !== POSTGRESQL && db !== MYSQL) {
@ -285,7 +285,15 @@ 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 params = [website_id, start_at, end_at];
let urlFilter = '';
if (url) {
urlFilter = `and url=$${params.length + 1}`;
params.push(decodeURIComponent(url));
}
return rawQuery( return rawQuery(
` `
select sum(t.c) as "pageviews", select sum(t.c) as "pageviews",
@ -300,12 +308,11 @@ export function getMetrics(website_id, start_at, end_at) {
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,
); );
} }
@ -316,7 +323,16 @@ export function getPageviews(
timezone = 'utc', timezone = 'utc',
unit = 'day', unit = 'day',
count = '*', count = '*',
url,
) { ) {
const params = [website_id, start_at, end_at];
let urlFilter = '';
if (url) {
urlFilter = `and url=$${params.length + 1}`;
params.push(decodeURIComponent(url));
}
return rawQuery( return rawQuery(
` `
select ${getDateQuery('created_at', unit, timezone)} t, select ${getDateQuery('created_at', unit, timezone)} t,
@ -324,16 +340,23 @@ export function getPageviews(
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 group by 1
order by 1 order by 1
`, `,
website_id, params,
start_at,
end_at,
); );
} }
export function getSessionMetrics(website_id, start_at, end_at, field) { export function getSessionMetrics(website_id, start_at, end_at, field, url) {
const params = [website_id, start_at, end_at];
let urlFilter = '';
if (url) {
urlFilter = `and url=$${params.length + 1}`;
params.push(decodeURIComponent(url));
}
return rawQuery( return rawQuery(
` `
select ${field} x, count(*) y select ${field} x, count(*) y
@ -343,18 +366,29 @@ export function getSessionMetrics(website_id, start_at, end_at, field) {
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 group by 1
order by 2 desc order by 2 desc
`, `,
website_id, params,
start_at,
end_at,
); );
} }
export function getPageviewMetrics(website_id, start_at, end_at, field, table, domain) { export function getPageviewMetrics(website_id, start_at, end_at, field, table, domain, url) {
const filter = domain ? `and ${field} not like '%${domain}%'` : ''; 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 (url) {
urlFilter = `and url=$${params.length + 1}`;
params.push(decodeURIComponent(url));
}
return rawQuery( return rawQuery(
` `
@ -362,18 +396,18 @@ export function getPageviewMetrics(website_id, start_at, end_at, field, table, d
from ${table} from ${table}
where website_id=$1 where website_id=$1
and created_at between $2 and $3 and created_at between $2 and $3
${filter} ${domainFilter}
${urlFilter}
group by 1 group by 1
order by 2 desc order by 2 desc
`, `,
website_id, params,
start_at,
end_at,
); );
} }
export function getActiveVisitors(website_id) { export function getActiveVisitors(website_id) {
const date = subMinutes(new Date(), 5); const date = subMinutes(new Date(), 5);
const params = [website_id, date];
return rawQuery( return rawQuery(
` `
@ -382,12 +416,19 @@ export function getActiveVisitors(website_id) {
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') { 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));
}
return rawQuery( return rawQuery(
` `
select select
@ -397,11 +438,10 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit =
from event from event
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
order by 2 order by 2
`, `,
website_id, params,
start_at,
end_at,
); );
} }

View File

@ -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 '';
}

View File

@ -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));

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "0.52.0", "version": "0.53.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",

View File

@ -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);
} }

View File

@ -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;

View File

@ -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 });

View File

@ -31,7 +31,7 @@ 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)) { if (domain && !DOMAIN_REGEX.test(domain)) {
return badRequest(res); return badRequest(res);
@ -42,7 +42,7 @@ export default async (req, res) => {
const endDate = new Date(+end_at); const endDate = new Date(+end_at);
if (sessionColumns.includes(type)) { if (sessionColumns.includes(type)) {
const data = await getSessionMetrics(websiteId, startDate, endDate, type); const data = await getSessionMetrics(websiteId, startDate, endDate, type, url);
return ok(res, data); return ok(res, data);
} }
@ -55,6 +55,7 @@ export default async (req, res) => {
getColumn(type), getColumn(type),
getTable(type), getTable(type),
domain, domain,
url,
); );
return ok(res, data); return ok(res, data);