Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-171-cloud-mode-env-variable

This commit is contained in:
Francis Cao 2023-02-14 10:59:36 -08:00
commit eea01b21cf
63 changed files with 388 additions and 969 deletions

View File

@ -20,7 +20,7 @@
"next"
],
"plugins": ["@typescript-eslint","prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"settings": {
"import/resolver": {
"alias": {
@ -46,7 +46,9 @@
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"import/no-anonymous-default-export": "off",
"@next/next/no-img-element": "off"
"@next/next/no-img-element": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off"
},
"globals": {
"React": "writable"

View File

@ -1,5 +1,4 @@
import EventDataForm from 'components/metrics/EventDataForm';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Button, Icon, Modal, Icons } from 'react-basics';
import { FormattedMessage } from 'react-intl';
@ -44,8 +43,4 @@ function EventDataButton({ websiteId }) {
);
}
EventDataButton.propTypes = {
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default EventDataButton;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import styles from './Favicon.module.css';
function getHostName(url) {
@ -20,8 +19,4 @@ function Favicon({ domain, ...props }) {
) : null;
}
Favicon.propTypes = {
domain: PropTypes.string,
};
export default Favicon;

View File

@ -1,24 +1,11 @@
import PropTypes from 'prop-types';
import ButtonLayout from 'components/layout/ButtonLayout';
import { ButtonGroup } from 'react-basics';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
function FilterButtons({ buttons, selected, onClick }) {
export default function FilterButtons({ items, selectedKey, onSelect }) {
return (
<ButtonLayout>
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
</ButtonLayout>
<Flexbox justifyContent="center">
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
);
}
FilterButtons.propTypes = {
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any.isRequired,
}),
),
selected: PropTypes.any,
onClick: PropTypes.func,
};
export default FilterButtons;

View File

@ -1,28 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Icon.module.css';
function Icon({ icon, className, size = 'medium', ...props }) {
return (
<div
className={classNames(styles.icon, className, {
[styles.xlarge]: size === 'xlarge',
[styles.large]: size === 'large',
[styles.medium]: size === 'medium',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
})}
{...props}
>
{icon}
</div>
);
}
Icon.propTypes = {
className: PropTypes.string,
icon: PropTypes.node.isRequired,
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
};
export default Icon;

View File

@ -1,35 +0,0 @@
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
vertical-align: middle;
}
.icon svg {
fill: currentColor;
}
.xlarge > svg {
width: 48px;
height: 48px;
}
.large > svg {
width: 24px;
height: 24px;
}
.medium > svg {
width: 16px;
height: 16px;
}
.small > svg {
width: 12px;
height: 12px;
}
.xsmall > svg {
width: 10px;
height: 10px;
}

View File

@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import NextLink from 'next/link';
import { Icon } from 'react-basics';
import styles from './Link.module.css';
function Link({ className, icon, children, size, iconRight, onClick, ...props }) {
return (
<NextLink {...props}>
<a
className={classNames(styles.link, className, {
[styles.large]: size === 'large',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
[styles.iconRight]: iconRight,
})}
onClick={onClick}
>
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
{children}
</a>
</NextLink>
);
}
Link.propTypes = {
className: PropTypes.string,
icon: PropTypes.node,
children: PropTypes.node,
size: PropTypes.oneOf(['large', 'small', 'xsmall']),
iconRight: PropTypes.bool,
};
export default Link;

View File

@ -1,42 +0,0 @@
a.link,
a.link:active,
a.link:visited {
position: relative;
color: var(--base900);
text-decoration: none;
display: inline-flex;
align-items: center;
}
a.link span {
border-bottom: 2px solid transparent;
}
a.link:hover span {
border-bottom: 2px solid var(--primary400);
}
a.link.large {
font-size: var(--font-size-lg);
}
a.link.small {
font-size: var(--font-size-sm);
}
a.link.xsmall {
font-size: var(--font-size-xs);
}
a.link .icon + * {
margin-left: 10px;
}
a.link.iconRight .icon {
order: 1;
margin-left: 10px;
}
a.link.iconRight .icon + * {
margin: 0;
}

View File

@ -1,69 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Menu.module.css';
function Menu({
options = [],
selectedOption,
className,
float,
align = 'left',
optionClassName,
selectedClassName,
onSelect = () => {},
}) {
return (
<div
className={classNames(styles.menu, className, {
[styles.float]: float,
[styles.top]: float === 'top',
[styles.bottom]: float === 'bottom',
[styles.left]: align === 'left',
[styles.right]: align === 'right',
})}
>
{options
.filter(({ hidden }) => !hidden)
.map(option => {
const { label, value, className: customClassName, render, divider } = option;
return render ? (
render(option)
) : (
<div
key={value}
className={classNames(styles.option, optionClassName, customClassName, {
[selectedClassName]: selectedOption === option,
[styles.selected]: selectedOption === option,
[styles.divider]: divider,
})}
onClick={e => onSelect(value, e)}
>
{label}
</div>
);
})}
</div>
);
}
Menu.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
className: PropTypes.string,
render: PropTypes.func,
divider: PropTypes.bool,
}),
),
selectedOption: PropTypes.any,
className: PropTypes.string,
float: PropTypes.oneOf(['top', 'bottom']),
align: PropTypes.oneOf(['left', 'right']),
optionClassName: PropTypes.string,
selectedClassName: PropTypes.string,
onSelect: PropTypes.func,
};
export default Menu;

View File

@ -1,49 +0,0 @@
.menu {
background: var(--base50);
border: 1px solid var(--base500);
border-radius: 4px;
overflow: hidden;
z-index: 100;
}
.option {
background: var(--base50);
padding: 4px 16px;
cursor: pointer;
white-space: nowrap;
}
.option:hover {
background: var(--base100);
}
.float {
position: absolute;
min-width: 100px;
}
.top {
bottom: 100%;
margin-bottom: 5px;
}
.bottom {
top: 100%;
margin-top: 5px;
}
.left {
left: 0;
}
.right {
right: 0;
}
.divider {
border-top: 1px solid var(--base300);
}
.selected {
font-weight: 600;
}

View File

@ -1,87 +0,0 @@
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Menu from 'components/common/Menu';
import useDocumentClick from 'hooks/useDocumentClick';
import styles from './MenuButton.module.css';
import { Button } from 'react-basics';
function MenuButton({
children,
value,
options,
buttonClassName,
buttonVariant,
menuClassName,
menuPosition = 'bottom',
menuAlign = 'right',
onSelect,
renderValue,
hideLabel,
}) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleSelect(value) {
onSelect(value);
setShowMenu(false);
}
function toggleMenu() {
setShowMenu(state => !state);
}
useDocumentClick(e => {
if (!ref.current?.contains(e.target)) {
setShowMenu(false);
}
});
return (
<div className={styles.container} ref={ref}>
<Button
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
onClick={toggleMenu}
variant={buttonVariant}
>
{!hideLabel && (
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
)}
{children}
</Button>
{showMenu && (
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float={menuPosition}
align={menuAlign}
/>
)}
</div>
);
}
MenuButton.propTypes = {
icon: PropTypes.node,
value: PropTypes.any,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
className: PropTypes.string,
render: PropTypes.func,
divider: PropTypes.bool,
}),
),
buttonClassName: PropTypes.string,
menuClassName: PropTypes.string,
menuPosition: PropTypes.oneOf(['top', 'bottom']),
menuAlign: PropTypes.oneOf(['left', 'right']),
onSelect: PropTypes.func,
renderValue: PropTypes.func,
};
export default MenuButton;

View File

@ -1,20 +0,0 @@
.container {
display: flex;
position: relative;
cursor: pointer;
}
.button {
border: 1px solid transparent;
border-radius: 4px;
}
.text {
font-size: var(--font-size-sm);
}
.open,
.open:hover {
background: var(--base50);
border: 1px solid var(--base500);
}

View File

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import styles from './NavMenu.module.css';
function NavMenu({ options = [], className, onSelect = () => {} }) {
const router = useRouter();
return (
<div className={classNames(styles.menu, className)}>
{options
.filter(({ hidden }) => !hidden)
.map(option => {
const { label, value, className: customClassName, render } = option;
return render ? (
render(option)
) : (
<div
key={value}
className={classNames(styles.option, customClassName, {
[styles.selected]: router.asPath === value,
})}
onClick={e => onSelect(value, e)}
>
{label}
</div>
);
})}
</div>
);
}
NavMenu.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
className: PropTypes.string,
render: PropTypes.func,
}),
),
className: PropTypes.string,
onSelect: PropTypes.func,
};
export default NavMenu;

View File

@ -1,22 +0,0 @@
.menu {
color: var(--base800);
border: 1px solid var(--base500);
border-radius: 4px;
overflow: hidden;
z-index: 2;
}
.option {
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
}
.option:hover {
background: var(--base75);
}
.selected {
color: var(--base900);
font-weight: 600;
}

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import styles from './NoData.module.css';
@ -11,8 +10,4 @@ function NoData({ className }) {
);
}
NoData.propTypes = {
className: PropTypes.string,
};
export default NoData;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { useCallback, useEffect, useRef, useState } from 'react';
import ReactTooltip from 'react-tooltip';
@ -58,9 +57,4 @@ const OverflowText = ({ children, tooltipId }) => {
);
};
OverflowText.propTypes = {
children: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
tooltipId: PropTypes.string.isRequired,
};
export default OverflowText;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Tag.module.css';
@ -6,9 +5,4 @@ function Tag({ className, children }) {
return <span className={classNames(styles.tag, className)}>{children}</span>;
}
Tag.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default Tag;

View File

@ -1,6 +1,5 @@
import { useState, useMemo } from 'react';
import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import classNames from 'classnames';
@ -89,15 +88,4 @@ function WorldMap({ data, className }) {
);
}
WorldMap.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
x: PropTypes.string,
y: PropTypes.number,
z: PropTypes.number,
}),
),
className: PropTypes.string,
};
export default WorldMap;

View File

@ -1,48 +1,32 @@
import { useState, useRef, useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useMeasure, useCombinedRefs } from 'react-basics';
import classNames from 'classnames';
import useSticky from 'hooks/useSticky';
import { UI_LAYOUT_BODY } from 'lib/constants';
export default function StickyHeader({
className,
stickyClassName,
stickyStyle,
children,
enabled = true,
children,
}) {
const [sticky, setSticky] = useState(false);
const ref = useRef();
const top = useRef(0);
useEffect(() => {
const checkPosition = () => {
if (ref.current) {
if (!top.current) {
top.current = ref.current.offsetTop + ref.current.offsetHeight;
}
const state = window.pageYOffset > top.current;
if (sticky !== state) {
setSticky(state);
}
}
};
if (enabled) {
checkPosition();
window.addEventListener('scroll', checkPosition);
}
return () => {
window.removeEventListener('scroll', checkPosition);
};
}, [sticky, enabled]);
const { ref: scrollRef, isSticky } = useSticky({ scrollElementId: UI_LAYOUT_BODY });
const { ref: measureRef, dimensions } = useMeasure();
return (
<div
ref={ref}
data-sticky={sticky}
className={classNames(className, { [stickyClassName]: sticky })}
style={sticky ? { ...stickyStyle, width: ref?.current?.clientWidth } : null}
ref={measureRef}
data-sticky={enabled && isSticky}
style={enabled && isSticky ? { height: dimensions.height } : null}
>
{children}
<div
ref={scrollRef}
className={classNames(className, { [stickyClassName]: enabled && isSticky })}
style={enabled && isSticky ? { ...stickyStyle, width: dimensions.width } : null}
>
{children}
</div>
</div>
);
}

View File

@ -18,14 +18,13 @@ export default function LanguageButton({ tooltipPosition = 'top' }) {
return (
<PopupTrigger>
<PopupTrigger action="hover">
<Tooltip label={formatMessage(labels.language)} position={tooltipPosition}>
<Button variant="quiet">
<Icon>
<Icons.Globe />
</Icon>
</Button>
<Tooltip position={tooltipPosition}>{formatMessage(labels.language)}</Tooltip>
</PopupTrigger>
</Tooltip>
<Popup position="right" alignment="end">
<div className={styles.menu}>
{items.map(({ value, label }) => {

View File

@ -1,4 +1,4 @@
import { Button, Icon, Icons, PopupTrigger, Tooltip } from 'react-basics';
import { Button, Icon, Icons, Tooltip } from 'react-basics';
import Link from 'next/link';
import { labels } from 'components/messages';
import { useIntl } from 'react-intl';
@ -8,14 +8,13 @@ export default function LogoutButton({ tooltipPosition = 'top' }) {
return (
<Link href="/logout">
<a>
<PopupTrigger action="hover">
<Tooltip label={formatMessage(labels.logout)} position={tooltipPosition}>
<Button variant="quiet">
<Icon>
<Icons.Logout />
</Icon>
</Button>
<Tooltip position={tooltipPosition}>{formatMessage(labels.logout)}</Tooltip>
</PopupTrigger>
</Tooltip>
</a>
</Link>
);

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { Button, Icon, Tooltip } from '../react-basics';
import { Button, Icon, Tooltip } from 'react-basics';
import useStore from 'store/queries';
import { setDateRange } from 'store/websites';
import useDateRange from 'hooks/useDateRange';

View File

@ -2,6 +2,7 @@ import { Container } from 'react-basics';
import Head from 'next/head';
import NavBar from 'components/layout/NavBar';
import useRequireLogin from 'hooks/useRequireLogin';
import { UI_LAYOUT_BODY } from 'lib/constants';
import styles from './AppLayout.module.css';
export default function AppLayout({ title, children }) {
@ -19,7 +20,7 @@ export default function AppLayout({ title, children }) {
<div className={styles.nav}>
<NavBar />
</div>
<div className={styles.body}>
<div className={styles.body} id={UI_LAYOUT_BODY}>
<Container>
<main>{children}</main>
</Container>

View File

@ -2,8 +2,6 @@
display: grid;
grid-template-rows: 1fr;
grid-template-columns: max-content 1fr;
height: 100vh;
overflow: hidden;
}
.nav {
@ -13,4 +11,5 @@
.body {
grid-area: 1 / 2;
overflow: auto;
height: 100vh;
}

View File

@ -1,16 +0,0 @@
import classNames from 'classnames';
import styles from './ButtonLayout.module.css';
export default function ButtonLayout({ className, children, align = 'center' }) {
return (
<div
className={classNames(styles.buttons, className, {
[styles.left]: align === 'left',
[styles.center]: align === 'center',
[styles.right]: align === 'right',
})}
>
{children}
</div>
);
}

View File

@ -1,21 +0,0 @@
.buttons {
display: flex;
align-items: center;
width: 100%;
}
.buttons button + * {
margin-left: 10px;
}
.center {
justify-content: center;
}
.left {
justify-content: flex-start;
}
.right {
justify-content: flex-end;
}

View File

@ -3,7 +3,6 @@ import { useRouter } from 'next/router';
import Script from 'next/script';
import classNames from 'classnames';
import { useIntl, defineMessages } from 'react-intl';
import Link from 'components/common/Link';
import { CURRENT_VERSION, HOMEPAGE_URL, REPO_URL } from 'lib/constants';
import styles from './Footer.module.css';
@ -22,15 +21,15 @@ export default function Footer({ className }) {
<div>
{formatMessage(messages.poweredBy, {
name: (
<Link href={HOMEPAGE_URL}>
<a href={HOMEPAGE_URL}>
<b>umami</b>
</Link>
</a>
),
})}
</div>
</Column>
<Column className={styles.version}>
<Link href={REPO_URL}>{`v${CURRENT_VERSION}`}</Link>
<a href={REPO_URL}>{`v${CURRENT_VERSION}`}</a>
</Column>
</Row>
{!pathname.includes('/share/') && <Script src={`/telemetry.js`} />}

View File

@ -1,30 +0,0 @@
import classNames from 'classnames';
import styles from './GridLayout.module.css';
export default function GridLayout({ className, children }) {
return <div className={classNames(styles.grid, className)}>{children}</div>;
}
export const GridRow = ({ className, children }) => {
return <div className={classNames(styles.row, className, 'row')}>{children}</div>;
};
export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => {
const classes = [];
classes.push(xs ? `col-${xs}` : 'col-12');
if (sm) {
classes.push(`col-sm-${sm}`);
}
if (md) {
classes.push(`col-md-${md}`);
}
if (lg) {
classes.push(`col-lg-${lg}`);
}
if (xl) {
classes.push(`col-lg-${xl}`);
}
return <div className={classNames(styles.col, classes, className)}>{children}</div>;
};

View File

@ -1,18 +0,0 @@
import { Row, cloneChildren } from 'react-basics';
import styles from './GridRow.module.css';
import classNames from 'classnames';
export default function GridRow(props) {
const { children, className, ...rowProps } = props;
return (
<Row {...rowProps} className={className}>
{breakpoint =>
cloneChildren(children, () => {
return {
className: classNames(styles.column, styles[breakpoint]),
};
})
}
</Row>
);
}

View File

@ -1,21 +0,0 @@
.column {
padding: 20px;
border-top: 1px solid var(--base200);
border-left: 1px solid var(--base200);
}
.column:first-child {
padding-left: 0;
border-left: 0;
}
.column:last-child {
padding-right: 0;
}
.column.xs,
.column.sm,
.column.md {
border-left: 0;
border-right: 0;
}

View File

@ -25,9 +25,14 @@
}
.icon {
position: absolute;
right: 0;
visibility: hidden;
position: absolute;
right: -10px;
border-radius: 100%;
color: var(--base50);
background: var(--base800);
height: 20px;
width: 20px;
}
.minimized.navbar {

View File

@ -2,7 +2,7 @@
flex: 1;
display: flex;
flex-direction: column;
padding: 30px;
background: var(--base50);
position: relative;
padding: 30px;
}

View File

@ -79,6 +79,11 @@ export const labels = defineMessages({
query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' },
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
views: { id: 'label.views', defaultMessage: 'View' },
none: { id: 'label.none', defaultMessage: 'None' },
clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
});
export const messages = defineMessages({

View File

@ -7,9 +7,7 @@ import { getDateRangeValues } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import { labels } from 'components/messages';
import styles from './DatePickerForm.module.css';
const FILTER_DAY = 'day';
const FILTER_RANGE = 'range';
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
export default function DatePickerForm({
startDate: defaultStartDate,

View File

@ -1,22 +1,29 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import { Button, Icon, Icons } from 'react-basics';
import { Button, Icon, Icons, Text } from 'react-basics';
import { labels } from 'components/messages';
import styles from './FilterTags.module.css';
export default function FilterTags({ className, params, onClick }) {
const { formatMessage } = useIntl();
if (Object.keys(params).filter(key => params[key]).length === 0) {
return null;
}
return (
<div className={classNames(styles.filters, 'col-12', className)}>
<div className={classNames(styles.filters, className)}>
{Object.keys(params).map(key => {
if (!params[key]) {
return null;
}
return (
<div key={key} className={styles.tag}>
<Button onClick={() => onClick(key)} variant="action" iconRight>
{`${key}: ${safeDecodeURI(params[key])}`}
<Button onClick={() => onClick(key)} variant="primary" size="sm">
<Text>
<b>{`${key}`}</b> {`${safeDecodeURI(params[key])}`}
</Text>
<Icon>
<Icons.Close />
</Icon>
@ -24,6 +31,12 @@ export default function FilterTags({ className, params, onClick }) {
</div>
);
})}
<Button size="sm" variant="quiet" onClick={() => onClick(null)}>
<Icon>
<Icons.Close />
</Icon>
<Text>{formatMessage(labels.clearAll)}</Text>
</Button>
</div>
);
}

View File

@ -2,22 +2,25 @@
display: flex;
flex-direction: column;
justify-content: center;
min-height: 90px;
min-width: 140px;
}
.value {
font-size: var(--font-size-2xl);
line-height: 40px;
min-height: 40px;
font-weight: 600;
display: flex;
align-items: center;
font-size: 36px;
font-weight: 700;
white-space: nowrap;
min-height: 60px;
}
.label {
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
min-height: 30px;
}
.change {

View File

@ -2,10 +2,7 @@
display: flex;
cursor: pointer;
min-height: 80px;
}
.bar > div + div {
padding-left: 20px;
gap: 20px;
}
@media only screen and (max-width: 992px) {

View File

@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { Loading, Icons } from 'react-basics';
import { Loading, Icon, Text, Button } from 'react-basics';
import { defineMessages, useIntl } from 'react-intl';
import Link from 'next/link';
import firstBy from 'thenby';
import classNames from 'classnames';
import Link from 'components/common/Link';
import useApi from 'hooks/useApi';
import { percentFilter } from 'lib/filters';
import useDateRange from 'hooks/useDateRange';
@ -11,6 +11,7 @@ import usePageQuery from 'hooks/usePageQuery';
import ErrorMessage from 'components/common/ErrorMessage';
import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import Icons from 'components/icons';
import styles from './MetricsTable.module.css';
const messages = defineMessages({
@ -78,14 +79,15 @@ export default function MetricsTable({
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
<div className={styles.footer}>
{data && !error && limit && (
<Link
icon={<Icons.ArrowRight />}
href={router.pathname}
as={resolve({ view: type })}
size="small"
iconRight
>
{formatMessage(messages.more)}
<Link href={router.pathname} as={resolve({ view: type })}>
<a>
<Button variant="quiet">
<Text>{formatMessage(messages.more)}</Text>
<Icon size="sm">
<Icons.ArrowRight />
</Icon>
</Button>
</a>
</Link>
)}
</div>

View File

@ -1,19 +1,16 @@
import { useState } from 'react';
import { useIntl, defineMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import FilterLink from 'components/common/FilterLink';
import FilterButtons from 'components/common/FilterButtons';
import { urlFilter } from 'lib/filters';
import { labels } from 'components/messages';
import MetricsTable from './MetricsTable';
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
export const FILTER_COMBINED = 0;
export const FILTER_RAW = 1;
const messages = defineMessage({
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
pages: { id: 'metrics.pages', defaultMessage: 'Pages' },
views: { id: 'metrics.views', defaultMessage: 'View' },
});
const filters = {
[FILTER_RAW]: null,
[FILTER_COMBINED]: urlFilter,
};
export default function PagesTable({ websiteId, showFilters, ...props }) {
const [filter, setFilter] = useState(FILTER_COMBINED);
@ -21,12 +18,12 @@ export default function PagesTable({ websiteId, showFilters, ...props }) {
const buttons = [
{
label: formatMessage(messages.combined),
value: FILTER_COMBINED,
label: formatMessage(labels.filterCombined),
key: FILTER_COMBINED,
},
{
label: formatMessage(messages.raw),
value: FILTER_RAW,
label: formatMessage(labels.filterRaw),
key: FILTER_RAW,
},
];
@ -36,13 +33,13 @@ export default function PagesTable({ websiteId, showFilters, ...props }) {
return (
<>
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
<MetricsTable
title={formatMessage(messages.pages)}
title={formatMessage(labels.pages)}
type="url"
metric={formatMessage(messages.views)}
metric={formatMessage(labels.views)}
websiteId={websiteId}
dataFilter={filter !== FILTER_RAW ? urlFilter : null}
dataFilter={filters[filter]}
renderLabel={renderLink}
{...props}
/>

View File

@ -1,21 +1,17 @@
import { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useIntl } from 'react-intl';
import { safeDecodeURI } from 'next-basics';
import Tag from 'components/common/Tag';
import FilterButtons from 'components/common/FilterButtons';
import { paramFilter } from 'lib/filters';
import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants';
import { labels } from 'components/messages';
import MetricsTable from './MetricsTable';
const FILTER_COMBINED = 0;
const FILTER_RAW = 1;
const messages = defineMessages({
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
views: { id: 'metrics.views', defaultMessage: 'Views' },
none: { id: 'label.none', defaultMessage: 'None' },
query: { id: 'metrics.query-parameters', defaultMessage: 'Query parameters' },
});
const filters = {
[FILTER_RAW]: null,
[FILTER_COMBINED]: paramFilter,
};
export default function QueryParametersTable({ websiteId, showFilters, ...props }) {
const [filter, setFilter] = useState(FILTER_COMBINED);
@ -23,22 +19,22 @@ export default function QueryParametersTable({ websiteId, showFilters, ...props
const buttons = [
{
label: formatMessage(messages.combined),
value: FILTER_COMBINED,
label: formatMessage(labels.filterCombined),
key: FILTER_COMBINED,
},
{ label: formatMessage(messages.raw), value: FILTER_RAW },
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
];
return (
<>
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
<MetricsTable
{...props}
title={formatMessage(messages.query)}
title={formatMessage(labels.query)}
type="query"
metric={formatMessage(messages.views)}
metric={formatMessage(labels.views)}
websiteId={websiteId}
dataFilter={filter !== FILTER_RAW ? paramFilter : null}
dataFilter={filters[filter]}
renderLabel={({ x, p, v }) =>
filter === FILTER_RAW ? (
x

View File

@ -54,19 +54,19 @@ export default function RealtimeLog({ data, websites, websiteId }) {
const buttons = [
{
label: <FormattedMessage id="label.all" defaultMessage="All" />,
value: TYPE_ALL,
key: TYPE_ALL,
},
{
label: <FormattedMessage id="metrics.views" defaultMessage="Views" />,
value: TYPE_PAGEVIEW,
key: TYPE_PAGEVIEW,
},
{
label: <FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />,
value: TYPE_SESSION,
key: TYPE_SESSION,
},
{
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
value: TYPE_EVENT,
key: TYPE_EVENT,
},
];
@ -165,7 +165,7 @@ export default function RealtimeLog({ data, websites, websiteId }) {
return (
<div className={styles.table}>
<FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
<div className={styles.header}>
<FormattedMessage id="label.realtime-logs" defaultMessage="Realtime logs" />
</div>

View File

@ -4,9 +4,7 @@ import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import DataTable from './DataTable';
import FilterButtons from 'components/common/FilterButtons';
const FILTER_REFERRERS = 0;
const FILTER_PAGES = 1;
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
export default function RealtimeViews({ websiteId, data, websites }) {
const { pageviews } = data;
@ -23,11 +21,11 @@ export default function RealtimeViews({ websiteId, data, websites }) {
const buttons = [
{
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
value: FILTER_REFERRERS,
key: FILTER_REFERRERS,
},
{
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
value: FILTER_PAGES,
key: FILTER_PAGES,
},
];
@ -90,7 +88,7 @@ export default function RealtimeViews({ websiteId, data, websites }) {
return (
<>
<FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
{filter === FILTER_REFERRERS && (
<DataTable
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}

View File

@ -1,52 +1,47 @@
import { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useIntl } from 'react-intl';
import MetricsTable from './MetricsTable';
import FilterButtons from 'components/common/FilterButtons';
import FilterLink from 'components/common/FilterLink';
import { refFilter } from 'lib/filters';
import { labels } from 'components/messages';
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
const FILTER_COMBINED = 0;
const FILTER_RAW = 1;
const messages = defineMessages({
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
referrers: { id: 'metrics.referrers', defaultMessage: 'Referrers' },
views: { id: 'metrics.views', defaultMessage: 'Views' },
none: { id: 'label.none', defaultMessage: 'None' },
});
const filters = {
[FILTER_RAW]: null,
[FILTER_COMBINED]: refFilter,
};
export default function ReferrersTable({ websiteId, showFilters, ...props }) {
const [filter, setFilter] = useState(FILTER_COMBINED);
const { formatMessage } = useIntl();
const none = formatMessage(messages.none);
const buttons = [
const items = [
{
label: formatMessage(messages.combined),
value: FILTER_COMBINED,
label: formatMessage(labels.filterCombined),
key: FILTER_COMBINED,
},
{ label: formatMessage(messages.raw), value: FILTER_RAW },
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
];
const renderLink = ({ w: link, x: referrer }) => {
return referrer ? (
<FilterLink id="referrer" value={referrer} externalUrl={link} />
) : (
`(${none})`
`(${formatMessage(labels.none)})`
);
};
return (
<>
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
{showFilters && <FilterButtons items={items} selectedKey={filter} onSelect={setFilter} />}
<MetricsTable
{...props}
title={formatMessage(messages.referrers)}
title={formatMessage(labels.referrers)}
type="referrer"
metric={formatMessage(messages.views)}
metric={formatMessage(labels.views)}
websiteId={websiteId}
dataFilter={filter !== FILTER_RAW ? refFilter : null}
dataFilter={filters[filter]}
renderLabel={renderLink}
/>
</>

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Button, Icon, Text, Row, Column, Loading } from 'react-basics';
import { Button, Icon, Text, Row, Column, Container } from 'react-basics';
import Link from 'next/link';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
@ -34,12 +34,12 @@ export default function WebsiteChart({
const {
router,
resolve,
query: { url, referrer, os, browser, device, country },
query: { view, url, referrer, os, browser, device, country },
} = usePageQuery();
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery(
['websites:pageviews', { websiteId, modified, url, referrer, os, browser, device, country }],
['websites:pageviews', websiteId, modified, url, referrer, os, browser, device, country],
() =>
get(`/websites/${websiteId}/pageviews`, {
startAt: +startDate,
@ -67,7 +67,11 @@ export default function WebsiteChart({
}, [data, startDate, endDate, unit]);
function handleCloseFilter(param) {
router.push(resolve({ [param]: undefined }));
if (param === null) {
router.push(`/websites/${websiteId}/?view=${view}`);
} else {
router.push(resolve({ [param]: undefined }));
}
}
async function handleDateChange(value) {
@ -82,10 +86,6 @@ export default function WebsiteChart({
}
}
if (isLoading) {
return <Loading icon="dots" />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
@ -102,15 +102,11 @@ export default function WebsiteChart({
</Link>
)}
</WebsiteHeader>
<StickyHeader
className={styles.metrics}
stickyClassName={styles.sticky}
enabled={stickyHeader}
>
<FilterTags
params={{ url, referrer, os, browser, device, country }}
onClick={handleCloseFilter}
/>
<FilterTags
params={{ url, referrer, os, browser, device, country }}
onClick={handleCloseFilter}
/>
<StickyHeader stickyClassName={styles.sticky} enabled={stickyHeader}>
<Row className={styles.header}>
<Column xs={12} sm={12} md={12} defaultSize={10}>
<MetricsBar websiteId={websiteId} />

View File

@ -17,24 +17,22 @@
}
.header {
min-height: 90px;
}
.metrics {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
min-height: 90px;
margin-bottom: 20px;
}
.sticky {
position: fixed;
top: 0;
margin: auto;
background: var(--base50);
border-bottom: 1px solid var(--base300);
z-index: 3;
width: inherit;
padding-top: 20px;
}
.filter {

View File

@ -1,8 +1,8 @@
import { defineMessages, useIntl } from 'react-intl';
import MenuButton from 'components/common/MenuButton';
import Gear from 'assets/gear.svg';
import { Menu, Icon, Text, PopupTrigger, Popup, Item, Button } from 'react-basics';
import Icons from 'components/icons';
import { labels } from 'components/messages';
import { saveDashboard } from 'store/dashboard';
import { Icon } from 'react-basics';
const messages = defineMessages({
toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' },
@ -33,10 +33,18 @@ export default function DashboardSettingsButton() {
}
return (
<MenuButton options={menuOptions} onSelect={handleSelect} hideLabel>
<Icon>
<Gear />
</Icon>
</MenuButton>
<PopupTrigger>
<Button>
<Icon>
<Icons.Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Button>
<Popup alignment="end">
<Menu variant="popup" items={menuOptions} onSelect={handleSelect}>
{({ label, value }) => <Item key={value}>{label}</Item>}
</Menu>
</Popup>
</PopupTrigger>
);
}

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Row, Column } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import { subMinutes, startOfMinute } from 'date-fns';
import firstBy from 'thenby';
import Page from 'components/layout/Page';
import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout';
import RealtimeChart from 'components/metrics/RealtimeChart';
import RealtimeLog from 'components/metrics/RealtimeLog';
import RealtimeHeader from 'components/metrics/RealtimeHeader';
@ -129,29 +129,27 @@ export default function RealtimeDashboard() {
<div className={styles.chart}>
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
</div>
<GridLayout>
<GridRow>
<GridColumn xs={12} lg={4}>
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
</GridColumn>
<GridColumn xs={12} lg={8}>
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
</GridColumn>
</GridRow>
<GridRow>
<GridColumn xs={12} lg={4}>
<DataTable
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
data={countries}
renderLabel={renderCountryName}
/>
</GridColumn>
<GridColumn xs={12} lg={8}>
<WorldMap data={countries} />
</GridColumn>
</GridRow>
</GridLayout>
<Row>
<Column xs={12} lg={4}>
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
</Column>
<Column xs={12} lg={8}>
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
</Column>
</Row>
<Row>
<Column xs={12} lg={4}>
<DataTable
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
data={countries}
renderLabel={renderCountryName}
/>
</Column>
<Column xs={12} lg={8}>
<WorldMap data={countries} />
</Column>
</Row>
</Page>
);
}

View File

@ -3,13 +3,12 @@ import { Icons, Loading } from 'react-basics';
import { useIntl } from 'react-intl';
import Link from 'next/link';
import classNames from 'classnames';
import MenuLayout from 'components/layout/MenuLayout';
import Page from 'components/layout/Page';
import WebsiteChart from 'components/metrics/WebsiteChart';
import useApi from 'hooks/useApi';
import usePageQuery from 'hooks/usePageQuery';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { labels, messages } from 'components/messages';
import { labels } from 'components/messages';
import styles from './WebsiteDetails.module.css';
import WebsiteTableView from './WebsiteTableView';
import WebsiteMenuView from './WebsiteMenuView';
@ -27,19 +26,6 @@ export default function WebsiteDetails({ websiteId }) {
query: { view },
} = usePageQuery();
const BackButton = () => (
<div key="back-button" className={classNames(styles.backButton, 'col-12')}>
<Link
key="back-button"
href={resolve({ view: undefined })}
icon={<Icons.ArrowRight />}
sizes="small"
>
{formatMessage(labels.back)}
</Link>
</div>
);
function handleDataLoad() {
if (!chartLoaded) {
setTimeout(() => setChartLoaded(true), DEFAULT_ANIMATION_DURATION);
@ -54,7 +40,7 @@ export default function WebsiteDetails({ websiteId }) {
domain={data?.domain}
onDataLoad={handleDataLoad}
showLink={false}
stickyHeader
stickyHeader={true}
/>
{!chartLoaded && <Loading icon="dots" />}
{chartLoaded && (

View File

@ -1,5 +1,7 @@
import { Row, Column, Menu, Item, Icon, Text, Button } from 'react-basics';
import { Row, Column, Menu, Item, Icon, Button, Flexbox, Text } from 'react-basics';
import { useIntl } from 'react-intl';
import Link from 'next/link';
import classNames from 'classnames';
import BrowsersTable from 'components/metrics/BrowsersTable';
import CountriesTable from 'components/metrics/CountriesTable';
import DevicesTable from 'components/metrics/DevicesTable';
@ -12,8 +14,8 @@ import ScreenTable from 'components/metrics/ScreenTable';
import EventsTable from 'components/metrics/EventsTable';
import usePageQuery from 'hooks/usePageQuery';
import Icons from 'components/icons';
import { labels } from '../../messages';
import { useIntl } from 'react-intl';
import { labels } from 'components/messages';
import styles from './WebsiteMenuView.module.css';
const views = {
url: PagesTable,
@ -37,77 +39,83 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
const items = [
{
key: 'url',
label: formatMessage(labels.pages),
value: resolve({ view: 'url' }),
},
{
key: 'referrer',
label: formatMessage(labels.referrers),
value: resolve({ view: 'referrer' }),
},
{
key: 'browser',
label: formatMessage(labels.browsers),
value: resolve({ view: 'browser' }),
},
{
key: 'os',
label: formatMessage(labels.os),
value: resolve({ view: 'os' }),
},
{
key: 'device',
label: formatMessage(labels.devices),
value: resolve({ view: 'device' }),
},
{
key: 'country',
label: formatMessage(labels.countries),
value: resolve({ view: 'country' }),
},
{
key: 'language',
label: formatMessage(labels.languages),
value: resolve({ view: 'language' }),
},
{
key: 'screen',
label: formatMessage(labels.screens),
value: resolve({ view: 'screen' }),
},
{
key: 'event',
label: formatMessage(labels.events),
value: resolve({ view: 'event' }),
},
{
key: 'query',
label: formatMessage(labels.query),
value: resolve({ view: 'query' }),
},
];
const DetailsComponent = views[view];
return (
<Row>
<Column>
<Button>
<Icon rotate={180}>
<Icons.ArrowRight />
</Icon>
{formatMessage(labels.back)}
</Button>
<Menu items={items}>
{({ value, label }) => (
<Link href={resolve()}>
<a>
<Item key={value}>{label}</Item>
</a>
</Link>
<Row className={styles.row}>
<Column defaultSize={3} className={classNames(styles.col, styles.menu)}>
<Link href={resolve({ view: undefined })}>
<a>
<Flexbox justifyContent="center">
<Button variant="quiet">
<Icon rotate={180}>
<Icons.ArrowRight />
</Icon>
<Text>{formatMessage(labels.back)}</Text>
</Button>
</Flexbox>
</a>
</Link>
<Menu items={items} selectedKey={view}>
{({ key, label }) => (
<Item key={key} className={styles.item}>
<Link href={resolve({ view: key })} shallow={true}>
<a>{label}</a>
</Link>
</Item>
)}
</Menu>
</Column>
<Column>
<Column defaultSize={9} className={classNames(styles.col, styles.data)}>
<DetailsComponent
websiteId={websiteId}
websiteDomain={websiteDomain}
height={500}
limit={false}
animate={false}
showFilters
virtualize
showFilters={true}
virtualize={true}
/>
</Column>
</Row>

View File

@ -0,0 +1,31 @@
.row {
border-top: 1px solid var(--base300);
}
.col {
border-left: 1px solid var(--base300);
padding: 30px;
}
.col:first-child {
padding-left: 0;
border-left: 0;
}
.menu {
gap: 20px;
}
.item a {
color: var(--font-color100);
flex: 1;
padding: var(--size300) var(--size600);
}
.item {
padding: 0;
}
.data {
min-height: 600px;
}

View File

@ -9,6 +9,7 @@ import WorldMap from 'components/common/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
import styles from './WebsiteTableView.module.css';
export default function WebsiteTableView({ websiteId }) {
const [countryData, setCountryData] = useState();
@ -19,38 +20,38 @@ export default function WebsiteTableView({ websiteId }) {
return (
<>
<Row>
<Column variant="two">
<Row className={styles.row}>
<Column className={styles.col} variant="two">
<PagesTable {...tableProps} />
</Column>
<Column variant="two">
<Column className={styles.col} variant="two">
<ReferrersTable {...tableProps} />
</Column>
</Row>
<Row>
<Column variant="three">
<Row className={styles.row}>
<Column className={styles.col} variant="three">
<BrowsersTable {...tableProps} />
</Column>
<Column variant="three">
<Column className={styles.col} variant="three">
<OSTable {...tableProps} />
</Column>
<Column variant="three">
<Column className={styles.col} variant="three">
<DevicesTable {...tableProps} />
</Column>
</Row>
<Row>
<Column xs={12} sm={12} md={12} defaultSize={8}>
<Row className={styles.row}>
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={8}>
<WorldMap data={countryData} />
</Column>
<Column xs={12} sm={12} md={12} defaultSize={4}>
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={4}>
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
</Column>
</Row>
<Row>
<Column xs={12} md={12} lg={4} defaultSize={4}>
<Row className={styles.row}>
<Column className={styles.col} xs={12} md={12} lg={4} defaultSize={4}>
<EventsTable {...tableProps} />
</Column>
<Column xs={12} md={12} lg={8} defaultSize={8}>
<Column className={styles.col} xs={12} md={12} lg={8} defaultSize={8}>
<EventsChart websiteId={websiteId} />
</Column>
</Row>

View File

@ -1,8 +1,3 @@
.grid {
display: flex;
flex-direction: column;
}
.col {
display: flex;
flex-direction: column;

27
hooks/useSticky.js Normal file
View File

@ -0,0 +1,27 @@
import { useState, useEffect, useRef } from 'react';
export default function useSticky({ scrollElementId, defaultSticky = false }) {
const [isSticky, setIsSticky] = useState(defaultSticky);
const ref = useRef(null);
const initialTop = useRef(null);
useEffect(() => {
const element = scrollElementId ? document.getElementById(scrollElementId) : window;
const handleScroll = () => {
setIsSticky(element.scrollTop > initialTop.current);
};
if (initialTop.current === null) {
initialTop.current = ref?.current?.offsetTop;
}
element.addEventListener('scroll', handleScroll);
return () => {
element.removeEventListener('scroll', handleScroll);
};
}, [ref, setIsSticky]);
return { ref, isSticky };
}

View File

@ -23,6 +23,16 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
export const REALTIME_RANGE = 30;
export const REALTIME_INTERVAL = 3000;
export const UI_LAYOUT_BODY = 'ui-layout-body';
export const FILTER_COMBINED = 'filter-combined';
export const FILTER_RAW = 'filter-raw';
export const FILTER_IGNORED = 'filter-ignored';
export const FILTER_DAY = 'filter-day';
export const FILTER_RANGE = 'filter-range';
export const FILTER_REFERRERS = 'filter-referrers';
export const FILTER_PAGES = 'filter-pages';
export const EVENT_TYPE = {
pageView: 1,
customEvent: 2,
@ -103,8 +113,6 @@ export const EVENT_COLORS = [
'#ffec16',
];
export const FILTER_IGNORED = Symbol.for('filter-ignored');
export const DOMAIN_REGEX =
/^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63})$/;

View File

@ -1,15 +1,11 @@
export const urlFilter = data => {
const isValidUrl = url => {
return url !== '' && url !== null && !url.startsWith('#');
return url !== '' && url !== null;
};
const cleanUrl = url => {
try {
const { pathname, search } = new URL(url, location.origin);
if (search.startsWith('?')) {
return `${pathname}${search}`;
}
const { pathname } = new URL(url, location.origin);
return pathname;
} catch {

View File

@ -39,8 +39,6 @@ export interface Share {
token: string;
}
export interface Empty {}
export interface WebsiteActive {
x: number;
}

View File

@ -62,7 +62,7 @@
"@prisma/client": "4.9.0",
"@tanstack/react-query": "^4.16.1",
"@umami/prisma-client": "^0.2.0",
"@umami/redis-client": "^0.1.0",
"@umami/redis-client": "^0.2.0",
"chalk": "^4.1.1",
"chart.js": "^2.9.4",
"classnames": "^2.3.1",
@ -92,9 +92,8 @@
"next-basics": "^0.26.0",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"prop-types": "^15.7.2",
"react": "^18.2.0",
"react-basics": "^0.64.0",
"react-basics": "^0.66.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0",
"react-intl": "^5.24.7",
@ -103,7 +102,6 @@
"react-tooltip": "^4.2.21",
"react-use-measure": "^2.0.4",
"react-window": "^1.8.6",
"redis": "^4.6.2",
"request-ip": "^3.3.0",
"semver": "^7.3.6",
"thenby": "^1.3.4",

View File

@ -1,12 +1,12 @@
const { Resolver } = require('dns').promises;
import isbot from 'isbot';
import ipaddr from 'ipaddr.js';
import { createToken, unauthorized, send, badRequest, forbidden } from 'next-basics';
import { createToken, ok, send, badRequest, forbidden } from 'next-basics';
import { savePageView, saveEvent } from 'queries';
import { useCors, useSession } from 'lib/middleware';
import { getJsonBody, getIpAddress } from 'lib/detect';
import { secret } from 'lib/crypto';
import { NextApiRequest, NextApiResponse } from 'next';
import { Resolver } from 'dns/promises';
export interface NextApiRequestCollect extends NextApiRequest {
session: {
@ -26,7 +26,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
await useCors(req, res);
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
return unauthorized(res);
return ok(res);
}
const { type, payload } = getJsonBody(req);
@ -61,7 +61,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
.map(n => resolver.resolve4(n.trim()).catch(() => {}));
await Promise.all(promises).then(resolvedIps => {
ips.push(...resolvedIps.filter(n => n).flatMap(n => n));
ips.push(...resolvedIps.filter(n => n).flatMap(n => n as string[]));
});
}

31
pages/api/me/websites.ts Normal file
View File

@ -0,0 +1,31 @@
import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { methodNotAllowed, ok } from 'next-basics';
import { getUserWebsites } from 'queries';
export interface WebsitesRequestBody {
name: string;
domain: string;
shareId: string;
}
export default async (
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
res: NextApiResponse,
) => {
await useCors(req, res);
await useAuth(req, res);
const {
user: { id: userId },
} = req.auth;
if (req.method === 'GET') {
const websites = await getUserWebsites(userId);
return ok(res, websites);
}
return methodNotAllowed(res);
};

View File

@ -1,63 +1,34 @@
import { Prisma } from '@prisma/client';
import { canCreateWebsite } from 'lib/auth';
import { uuid } from 'lib/crypto';
import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { createWebsite, getUserWebsites } from 'queries';
export interface WebsitesRequestQuery {}
import { getUserWebsites } from 'queries';
export interface WebsitesRequestBody {
name: string;
domain: string;
shareId: string;
teamId?: string;
}
export default async (
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
res: NextApiResponse,
) => {
await useCors(req, res);
await useAuth(req, res);
const {
user: { id: userId },
} = req.auth;
const { id } = req.query;
const { user } = req.auth;
const { id: userId } = req.query;
if (req.method === 'GET') {
const websites = await getUserWebsites(id as string);
if (!user.isAdmin && user.id !== userId) {
return unauthorized(res);
}
const websites = await getUserWebsites(userId);
return ok(res, websites);
}
if (req.method === 'POST') {
const { name, domain, shareId, teamId } = req.body;
if (!(await canCreateWebsite(req.auth, teamId))) {
return unauthorized(res);
}
const data: Prisma.WebsiteUncheckedCreateInput = {
id: uuid(),
name,
domain,
shareId,
};
if (teamId) {
data.teamId = teamId;
} else {
data.userId = userId;
}
const website = await createWebsite(data);
return ok(res, website);
}
return methodNotAllowed(res);
};

View File

@ -90,15 +90,15 @@ export default async (
});
if (type === 'language') {
let combined = {};
const combined = {};
for (let { x, y } of data) {
x = String(x).toLowerCase().split('-')[0];
for (const { x, y } of data) {
const key = String(x).toLowerCase().split('-')[0];
if (!combined[x]) {
combined[x] = { x, y };
if (!combined[key]) {
combined[key] = { x, y };
} else {
combined[x].y += y;
combined[key].y += y;
}
}

View File

@ -6,8 +6,6 @@ import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { createWebsite, getUserWebsites } from 'queries';
export interface WebsitesRequestQuery {}
export interface WebsitesRequestBody {
name: string;
domain: string;
@ -16,7 +14,7 @@ export interface WebsitesRequestBody {
}
export default async (
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
res: NextApiResponse,
) => {
await useCors(req, res);

View File

@ -1930,11 +1930,6 @@
resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.1.0.tgz#64e310ddee72010676e14296076329e594a1f6c7"
integrity sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==
"@redis/bloom@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"
integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==
"@redis/client@1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.4.2.tgz#2a3f5e98bc33b7b979390442e6e08f96e57fabdd"
@ -1944,15 +1939,6 @@
generic-pool "3.9.0"
yallist "4.0.0"
"@redis/client@1.5.3":
version "1.5.3"
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.5.3.tgz#2295ca770c9c40dcc59a96da9d05f4403c32e847"
integrity sha512-kPad3QmWyRcmFj1gnb+SkzjXBV7oPpyTJmasVA+ocgNClxqZaTJjLFReqxm9cZQiCtqZK9vrcTISNrgzQXFpLg==
dependencies:
cluster-key-slot "1.1.2"
generic-pool "3.9.0"
yallist "4.0.0"
"@redis/graph@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.0.tgz#cc2b82e5141a29ada2cce7d267a6b74baa6dd519"
@ -1968,11 +1954,6 @@
resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.0.tgz#7abb18d431f27ceafe6bcb4dd83a3fa67e9ab4df"
integrity sha512-NyFZEVnxIJEybpy+YskjgOJRNsfTYqaPbK/Buv6W2kmFNaRk85JiqjJZA5QkRmWvGbyQYwoO5QfDi2wHskKrQQ==
"@redis/search@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.1.tgz#f547b76b74f267831d3b368e3d7bba3a6a9e32bd"
integrity sha512-pqCXTc5e7wJJgUuJiC3hBgfoFRoPxYzwn0BEfKgejTM7M/9zP3IpUcqcjgfp8hF+LoV8rHZzcNTz7V+pEIY7LQ==
"@redis/time-series@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717"
@ -2435,10 +2416,10 @@
dependencies:
debug "^4.3.4"
"@umami/redis-client@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.1.0.tgz#69599b1243406a3aef83927bb8655a3ef4c0c031"
integrity sha512-XCqCdc3xA5KDOorIUvOVlz+/F/SEyL9cKWfCCGYYZix1b9yFo+5BzM0C0q7Yu/qV+1jYd+viNsBQQSM6a8sfjg==
"@umami/redis-client@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.2.0.tgz#bdb1cd8b5c99afc5230621f19296c6d3559d68af"
integrity sha512-TONWhkuC//K2hRo3Psk7FHsuvu3XkQIYMY62/CERPtlIJz4Ac7DqsmYw4jO9/RkljA9XLl/5u+OggD4ARhMV8A==
dependencies:
debug "^4.3.4"
redis "^4.5.1"
@ -3147,7 +3128,7 @@ cluster-key-slot@1.1.1:
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz#10ccb9ded0729464b6d2e7d714b100a2d1259d43"
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0:
cluster-key-slot@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
@ -6695,10 +6676,10 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-basics@^0.64.0:
version "0.64.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.64.0.tgz#b921dab7e437db6655f033cae15b8c963b93b7b2"
integrity sha512-MY/F5+VBqqi+Hx58PdRONoeu3W0sitPOFbvAGxiM9vpajQL1DD//0Xgl/MahW8sIDbMy00lFghouex5JS93C8Q==
react-basics@^0.66.0:
version "0.66.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.66.0.tgz#c9d756936e9e996cbd70fb83e164fc673932fa0d"
integrity sha512-VKndXhIyWb/e080/TUXB2WnEfF/DSFSExer8AcxXJk58Illbg6bqMLjGuP2gtJ/dvr5skSPqm8b9gLVFU9C1ig==
dependencies:
classnames "^2.3.1"
date-fns "^2.29.3"
@ -6932,18 +6913,6 @@ redis@^4.5.1:
"@redis/search" "1.1.0"
"@redis/time-series" "1.0.4"
redis@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/redis/-/redis-4.6.2.tgz#5592db7b95c1b6652cde463e58a8029a1f7890bb"
integrity sha512-Xoh7UyU6YnT458xA8svaZAJu6ZunKeW7Z/7GXrLWGGwhVLTsDX6pr3u7ENAoV+DHBPO+9LwIu45ClwUwpIjAxw==
dependencies:
"@redis/bloom" "1.2.0"
"@redis/client" "1.5.3"
"@redis/graph" "1.1.0"
"@redis/json" "1.0.4"
"@redis/search" "1.1.1"
"@redis/time-series" "1.0.4"
redux@^4.0.0, redux@^4.0.4:
version "4.2.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"