mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-171-cloud-mode-env-variable
This commit is contained in:
commit
eea01b21cf
@ -20,7 +20,7 @@
|
|||||||
"next"
|
"next"
|
||||||
],
|
],
|
||||||
|
|
||||||
"plugins": ["@typescript-eslint","prettier"],
|
"plugins": ["@typescript-eslint", "prettier"],
|
||||||
"settings": {
|
"settings": {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
"alias": {
|
"alias": {
|
||||||
@ -46,7 +46,9 @@
|
|||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
"react/prop-types": "off",
|
"react/prop-types": "off",
|
||||||
"import/no-anonymous-default-export": "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": {
|
"globals": {
|
||||||
"React": "writable"
|
"React": "writable"
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import EventDataForm from 'components/metrics/EventDataForm';
|
import EventDataForm from 'components/metrics/EventDataForm';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Icon, Modal, Icons } from 'react-basics';
|
import { Button, Icon, Modal, Icons } from 'react-basics';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
@ -44,8 +43,4 @@ function EventDataButton({ websiteId }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventDataButton.propTypes = {
|
|
||||||
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventDataButton;
|
export default EventDataButton;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import styles from './Favicon.module.css';
|
import styles from './Favicon.module.css';
|
||||||
|
|
||||||
function getHostName(url) {
|
function getHostName(url) {
|
||||||
@ -20,8 +19,4 @@ function Favicon({ domain, ...props }) {
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Favicon.propTypes = {
|
|
||||||
domain: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Favicon;
|
export default Favicon;
|
||||||
|
@ -1,24 +1,11 @@
|
|||||||
import PropTypes from 'prop-types';
|
import { ButtonGroup, Button, Flexbox } from 'react-basics';
|
||||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
|
||||||
import { ButtonGroup } from 'react-basics';
|
|
||||||
|
|
||||||
function FilterButtons({ buttons, selected, onClick }) {
|
export default function FilterButtons({ items, selectedKey, onSelect }) {
|
||||||
return (
|
return (
|
||||||
<ButtonLayout>
|
<Flexbox justifyContent="center">
|
||||||
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
|
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
|
||||||
</ButtonLayout>
|
{({ 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;
|
|
||||||
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
@ -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);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
@ -1,4 +1,3 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import styles from './NoData.module.css';
|
import styles from './NoData.module.css';
|
||||||
@ -11,8 +10,4 @@ function NoData({ className }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
NoData.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NoData;
|
export default NoData;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import ReactTooltip from 'react-tooltip';
|
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;
|
export default OverflowText;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import styles from './Tag.module.css';
|
import styles from './Tag.module.css';
|
||||||
|
|
||||||
@ -6,9 +5,4 @@ function Tag({ className, children }) {
|
|||||||
return <span className={classNames(styles.tag, className)}>{children}</span>;
|
return <span className={classNames(styles.tag, className)}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Tag.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tag;
|
export default Tag;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import ReactTooltip from 'react-tooltip';
|
import ReactTooltip from 'react-tooltip';
|
||||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||||
import classNames from 'classnames';
|
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;
|
export default WorldMap;
|
||||||
|
@ -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 classNames from 'classnames';
|
||||||
|
import useSticky from 'hooks/useSticky';
|
||||||
|
import { UI_LAYOUT_BODY } from 'lib/constants';
|
||||||
|
|
||||||
export default function StickyHeader({
|
export default function StickyHeader({
|
||||||
className,
|
className,
|
||||||
stickyClassName,
|
stickyClassName,
|
||||||
stickyStyle,
|
stickyStyle,
|
||||||
children,
|
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
children,
|
||||||
}) {
|
}) {
|
||||||
const [sticky, setSticky] = useState(false);
|
const { ref: scrollRef, isSticky } = useSticky({ scrollElementId: UI_LAYOUT_BODY });
|
||||||
const ref = useRef();
|
const { ref: measureRef, dimensions } = useMeasure();
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={measureRef}
|
||||||
data-sticky={sticky}
|
data-sticky={enabled && isSticky}
|
||||||
className={classNames(className, { [stickyClassName]: sticky })}
|
style={enabled && isSticky ? { height: dimensions.height } : null}
|
||||||
style={sticky ? { ...stickyStyle, width: ref?.current?.clientWidth } : null}
|
|
||||||
>
|
>
|
||||||
{children}
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={classNames(className, { [stickyClassName]: enabled && isSticky })}
|
||||||
|
style={enabled && isSticky ? { ...stickyStyle, width: dimensions.width } : null}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,14 +18,13 @@ export default function LanguageButton({ tooltipPosition = 'top' }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PopupTrigger>
|
<PopupTrigger>
|
||||||
<PopupTrigger action="hover">
|
<Tooltip label={formatMessage(labels.language)} position={tooltipPosition}>
|
||||||
<Button variant="quiet">
|
<Button variant="quiet">
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Globe />
|
<Icons.Globe />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip position={tooltipPosition}>{formatMessage(labels.language)}</Tooltip>
|
</Tooltip>
|
||||||
</PopupTrigger>
|
|
||||||
<Popup position="right" alignment="end">
|
<Popup position="right" alignment="end">
|
||||||
<div className={styles.menu}>
|
<div className={styles.menu}>
|
||||||
{items.map(({ value, label }) => {
|
{items.map(({ value, label }) => {
|
||||||
|
@ -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 Link from 'next/link';
|
||||||
import { labels } from 'components/messages';
|
import { labels } from 'components/messages';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@ -8,14 +8,13 @@ export default function LogoutButton({ tooltipPosition = 'top' }) {
|
|||||||
return (
|
return (
|
||||||
<Link href="/logout">
|
<Link href="/logout">
|
||||||
<a>
|
<a>
|
||||||
<PopupTrigger action="hover">
|
<Tooltip label={formatMessage(labels.logout)} position={tooltipPosition}>
|
||||||
<Button variant="quiet">
|
<Button variant="quiet">
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Logout />
|
<Icons.Logout />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip position={tooltipPosition}>{formatMessage(labels.logout)}</Tooltip>
|
</Tooltip>
|
||||||
</PopupTrigger>
|
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
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 useStore from 'store/queries';
|
||||||
import { setDateRange } from 'store/websites';
|
import { setDateRange } from 'store/websites';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
|
@ -2,6 +2,7 @@ import { Container } from 'react-basics';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import NavBar from 'components/layout/NavBar';
|
import NavBar from 'components/layout/NavBar';
|
||||||
import useRequireLogin from 'hooks/useRequireLogin';
|
import useRequireLogin from 'hooks/useRequireLogin';
|
||||||
|
import { UI_LAYOUT_BODY } from 'lib/constants';
|
||||||
import styles from './AppLayout.module.css';
|
import styles from './AppLayout.module.css';
|
||||||
|
|
||||||
export default function AppLayout({ title, children }) {
|
export default function AppLayout({ title, children }) {
|
||||||
@ -19,7 +20,7 @@ export default function AppLayout({ title, children }) {
|
|||||||
<div className={styles.nav}>
|
<div className={styles.nav}>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
<div className={styles.body} id={UI_LAYOUT_BODY}>
|
||||||
<Container>
|
<Container>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
</Container>
|
</Container>
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
grid-template-columns: max-content 1fr;
|
grid-template-columns: max-content 1fr;
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@ -13,4 +11,5 @@
|
|||||||
.body {
|
.body {
|
||||||
grid-area: 1 / 2;
|
grid-area: 1 / 2;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -3,7 +3,6 @@ import { useRouter } from 'next/router';
|
|||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
import Link from 'components/common/Link';
|
|
||||||
import { CURRENT_VERSION, HOMEPAGE_URL, REPO_URL } from 'lib/constants';
|
import { CURRENT_VERSION, HOMEPAGE_URL, REPO_URL } from 'lib/constants';
|
||||||
import styles from './Footer.module.css';
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
@ -22,15 +21,15 @@ export default function Footer({ className }) {
|
|||||||
<div>
|
<div>
|
||||||
{formatMessage(messages.poweredBy, {
|
{formatMessage(messages.poweredBy, {
|
||||||
name: (
|
name: (
|
||||||
<Link href={HOMEPAGE_URL}>
|
<a href={HOMEPAGE_URL}>
|
||||||
<b>umami</b>
|
<b>umami</b>
|
||||||
</Link>
|
</a>
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
<Column className={styles.version}>
|
<Column className={styles.version}>
|
||||||
<Link href={REPO_URL}>{`v${CURRENT_VERSION}`}</Link>
|
<a href={REPO_URL}>{`v${CURRENT_VERSION}`}</a>
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
{!pathname.includes('/share/') && <Script src={`/telemetry.js`} />}
|
{!pathname.includes('/share/') && <Script src={`/telemetry.js`} />}
|
||||||
|
@ -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>;
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -25,9 +25,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
border-radius: 100%;
|
||||||
|
color: var(--base50);
|
||||||
|
background: var(--base800);
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimized.navbar {
|
.minimized.navbar {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 30px;
|
|
||||||
background: var(--base50);
|
background: var(--base50);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 30px;
|
||||||
}
|
}
|
||||||
|
@ -79,6 +79,11 @@ export const labels = defineMessages({
|
|||||||
query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
|
query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
|
||||||
back: { id: 'label.back', defaultMessage: 'Back' },
|
back: { id: 'label.back', defaultMessage: 'Back' },
|
||||||
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
|
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({
|
export const messages = defineMessages({
|
||||||
|
@ -7,9 +7,7 @@ import { getDateRangeValues } from 'lib/date';
|
|||||||
import { getDateLocale } from 'lib/lang';
|
import { getDateLocale } from 'lib/lang';
|
||||||
import { labels } from 'components/messages';
|
import { labels } from 'components/messages';
|
||||||
import styles from './DatePickerForm.module.css';
|
import styles from './DatePickerForm.module.css';
|
||||||
|
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
|
||||||
const FILTER_DAY = 'day';
|
|
||||||
const FILTER_RANGE = 'range';
|
|
||||||
|
|
||||||
export default function DatePickerForm({
|
export default function DatePickerForm({
|
||||||
startDate: defaultStartDate,
|
startDate: defaultStartDate,
|
||||||
|
@ -1,22 +1,29 @@
|
|||||||
|
import { useIntl } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { safeDecodeURI } from 'next-basics';
|
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';
|
import styles from './FilterTags.module.css';
|
||||||
|
|
||||||
export default function FilterTags({ className, params, onClick }) {
|
export default function FilterTags({ className, params, onClick }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
if (Object.keys(params).filter(key => params[key]).length === 0) {
|
if (Object.keys(params).filter(key => params[key]).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.filters, 'col-12', className)}>
|
<div className={classNames(styles.filters, className)}>
|
||||||
{Object.keys(params).map(key => {
|
{Object.keys(params).map(key => {
|
||||||
if (!params[key]) {
|
if (!params[key]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={key} className={styles.tag}>
|
<div key={key} className={styles.tag}>
|
||||||
<Button onClick={() => onClick(key)} variant="action" iconRight>
|
<Button onClick={() => onClick(key)} variant="primary" size="sm">
|
||||||
{`${key}: ${safeDecodeURI(params[key])}`}
|
<Text>
|
||||||
|
<b>{`${key}`}</b> — {`${safeDecodeURI(params[key])}`}
|
||||||
|
</Text>
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Close />
|
<Icons.Close />
|
||||||
</Icon>
|
</Icon>
|
||||||
@ -24,6 +31,12 @@ export default function FilterTags({ className, params, onClick }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Button size="sm" variant="quiet" onClick={() => onClick(null)}>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Close />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.clearAll)}</Text>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,22 +2,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-height: 90px;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: var(--font-size-2xl);
|
display: flex;
|
||||||
line-height: 40px;
|
align-items: center;
|
||||||
min-height: 40px;
|
font-size: 36px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
white-space: nowrap;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change {
|
.change {
|
||||||
|
@ -2,10 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
gap: 20px;
|
||||||
|
|
||||||
.bar > div + div {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
@media only screen and (max-width: 992px) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
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 { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
import firstBy from 'thenby';
|
import firstBy from 'thenby';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Link from 'components/common/Link';
|
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import { percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
@ -11,6 +11,7 @@ import usePageQuery from 'hooks/usePageQuery';
|
|||||||
import ErrorMessage from 'components/common/ErrorMessage';
|
import ErrorMessage from 'components/common/ErrorMessage';
|
||||||
import DataTable from './DataTable';
|
import DataTable from './DataTable';
|
||||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||||
|
import Icons from 'components/icons';
|
||||||
import styles from './MetricsTable.module.css';
|
import styles from './MetricsTable.module.css';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -78,14 +79,15 @@ export default function MetricsTable({
|
|||||||
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{data && !error && limit && (
|
{data && !error && limit && (
|
||||||
<Link
|
<Link href={router.pathname} as={resolve({ view: type })}>
|
||||||
icon={<Icons.ArrowRight />}
|
<a>
|
||||||
href={router.pathname}
|
<Button variant="quiet">
|
||||||
as={resolve({ view: type })}
|
<Text>{formatMessage(messages.more)}</Text>
|
||||||
size="small"
|
<Icon size="sm">
|
||||||
iconRight
|
<Icons.ArrowRight />
|
||||||
>
|
</Icon>
|
||||||
{formatMessage(messages.more)}
|
</Button>
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,19 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl, defineMessage } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import FilterLink from 'components/common/FilterLink';
|
import FilterLink from 'components/common/FilterLink';
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
import FilterButtons from 'components/common/FilterButtons';
|
||||||
import { urlFilter } from 'lib/filters';
|
import { urlFilter } from 'lib/filters';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
|
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
||||||
|
|
||||||
export const FILTER_COMBINED = 0;
|
const filters = {
|
||||||
export const FILTER_RAW = 1;
|
[FILTER_RAW]: null,
|
||||||
|
[FILTER_COMBINED]: urlFilter,
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function PagesTable({ websiteId, showFilters, ...props }) {
|
export default function PagesTable({ websiteId, showFilters, ...props }) {
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
@ -21,12 +18,12 @@ export default function PagesTable({ websiteId, showFilters, ...props }) {
|
|||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.combined),
|
label: formatMessage(labels.filterCombined),
|
||||||
value: FILTER_COMBINED,
|
key: FILTER_COMBINED,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.raw),
|
label: formatMessage(labels.filterRaw),
|
||||||
value: FILTER_RAW,
|
key: FILTER_RAW,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -36,13 +33,13 @@ export default function PagesTable({ websiteId, showFilters, ...props }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
title={formatMessage(messages.pages)}
|
title={formatMessage(labels.pages)}
|
||||||
type="url"
|
type="url"
|
||||||
metric={formatMessage(messages.views)}
|
metric={formatMessage(labels.views)}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
dataFilter={filter !== FILTER_RAW ? urlFilter : null}
|
dataFilter={filters[filter]}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
@ -1,21 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { safeDecodeURI } from 'next-basics';
|
import { safeDecodeURI } from 'next-basics';
|
||||||
import Tag from 'components/common/Tag';
|
import Tag from 'components/common/Tag';
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
import FilterButtons from 'components/common/FilterButtons';
|
||||||
import { paramFilter } from 'lib/filters';
|
import { paramFilter } from 'lib/filters';
|
||||||
|
import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
|
|
||||||
const FILTER_COMBINED = 0;
|
const filters = {
|
||||||
const FILTER_RAW = 1;
|
[FILTER_RAW]: null,
|
||||||
|
[FILTER_COMBINED]: paramFilter,
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function QueryParametersTable({ websiteId, showFilters, ...props }) {
|
export default function QueryParametersTable({ websiteId, showFilters, ...props }) {
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
@ -23,22 +19,22 @@ export default function QueryParametersTable({ websiteId, showFilters, ...props
|
|||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.combined),
|
label: formatMessage(labels.filterCombined),
|
||||||
value: FILTER_COMBINED,
|
key: FILTER_COMBINED,
|
||||||
},
|
},
|
||||||
{ label: formatMessage(messages.raw), value: FILTER_RAW },
|
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
{...props}
|
{...props}
|
||||||
title={formatMessage(messages.query)}
|
title={formatMessage(labels.query)}
|
||||||
type="query"
|
type="query"
|
||||||
metric={formatMessage(messages.views)}
|
metric={formatMessage(labels.views)}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
dataFilter={filter !== FILTER_RAW ? paramFilter : null}
|
dataFilter={filters[filter]}
|
||||||
renderLabel={({ x, p, v }) =>
|
renderLabel={({ x, p, v }) =>
|
||||||
filter === FILTER_RAW ? (
|
filter === FILTER_RAW ? (
|
||||||
x
|
x
|
||||||
|
@ -54,19 +54,19 @@ export default function RealtimeLog({ data, websites, websiteId }) {
|
|||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="label.all" defaultMessage="All" />,
|
label: <FormattedMessage id="label.all" defaultMessage="All" />,
|
||||||
value: TYPE_ALL,
|
key: TYPE_ALL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.views" defaultMessage="Views" />,
|
label: <FormattedMessage id="metrics.views" defaultMessage="Views" />,
|
||||||
value: TYPE_PAGEVIEW,
|
key: TYPE_PAGEVIEW,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />,
|
label: <FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />,
|
||||||
value: TYPE_SESSION,
|
key: TYPE_SESSION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
|
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 (
|
return (
|
||||||
<div className={styles.table}>
|
<div className={styles.table}>
|
||||||
<FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
|
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<FormattedMessage id="label.realtime-logs" defaultMessage="Realtime logs" />
|
<FormattedMessage id="label.realtime-logs" defaultMessage="Realtime logs" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,9 +4,7 @@ import firstBy from 'thenby';
|
|||||||
import { percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import DataTable from './DataTable';
|
import DataTable from './DataTable';
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
import FilterButtons from 'components/common/FilterButtons';
|
||||||
|
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
|
||||||
const FILTER_REFERRERS = 0;
|
|
||||||
const FILTER_PAGES = 1;
|
|
||||||
|
|
||||||
export default function RealtimeViews({ websiteId, data, websites }) {
|
export default function RealtimeViews({ websiteId, data, websites }) {
|
||||||
const { pageviews } = data;
|
const { pageviews } = data;
|
||||||
@ -23,11 +21,11 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
||||||
value: FILTER_REFERRERS,
|
key: FILTER_REFERRERS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
|
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
||||||
{filter === FILTER_REFERRERS && (
|
{filter === FILTER_REFERRERS && (
|
||||||
<DataTable
|
<DataTable
|
||||||
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
||||||
|
@ -1,52 +1,47 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import FilterButtons from 'components/common/FilterButtons';
|
import FilterButtons from 'components/common/FilterButtons';
|
||||||
import FilterLink from 'components/common/FilterLink';
|
import FilterLink from 'components/common/FilterLink';
|
||||||
import { refFilter } from 'lib/filters';
|
import { refFilter } from 'lib/filters';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
||||||
|
|
||||||
const FILTER_COMBINED = 0;
|
const filters = {
|
||||||
const FILTER_RAW = 1;
|
[FILTER_RAW]: null,
|
||||||
|
[FILTER_COMBINED]: refFilter,
|
||||||
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' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ReferrersTable({ websiteId, showFilters, ...props }) {
|
export default function ReferrersTable({ websiteId, showFilters, ...props }) {
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const none = formatMessage(messages.none);
|
|
||||||
|
|
||||||
const buttons = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: formatMessage(messages.combined),
|
label: formatMessage(labels.filterCombined),
|
||||||
value: FILTER_COMBINED,
|
key: FILTER_COMBINED,
|
||||||
},
|
},
|
||||||
{ label: formatMessage(messages.raw), value: FILTER_RAW },
|
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderLink = ({ w: link, x: referrer }) => {
|
const renderLink = ({ w: link, x: referrer }) => {
|
||||||
return referrer ? (
|
return referrer ? (
|
||||||
<FilterLink id="referrer" value={referrer} externalUrl={link} />
|
<FilterLink id="referrer" value={referrer} externalUrl={link} />
|
||||||
) : (
|
) : (
|
||||||
`(${none})`
|
`(${formatMessage(labels.none)})`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
{showFilters && <FilterButtons items={items} selectedKey={filter} onSelect={setFilter} />}
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
{...props}
|
{...props}
|
||||||
title={formatMessage(messages.referrers)}
|
title={formatMessage(labels.referrers)}
|
||||||
type="referrer"
|
type="referrer"
|
||||||
metric={formatMessage(messages.views)}
|
metric={formatMessage(labels.views)}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
dataFilter={filter !== FILTER_RAW ? refFilter : null}
|
dataFilter={filters[filter]}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
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 Link from 'next/link';
|
||||||
import PageviewsChart from './PageviewsChart';
|
import PageviewsChart from './PageviewsChart';
|
||||||
import MetricsBar from './MetricsBar';
|
import MetricsBar from './MetricsBar';
|
||||||
@ -34,12 +34,12 @@ export default function WebsiteChart({
|
|||||||
const {
|
const {
|
||||||
router,
|
router,
|
||||||
resolve,
|
resolve,
|
||||||
query: { url, referrer, os, browser, device, country },
|
query: { view, url, referrer, os, browser, device, country },
|
||||||
} = usePageQuery();
|
} = usePageQuery();
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery(
|
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`, {
|
get(`/websites/${websiteId}/pageviews`, {
|
||||||
startAt: +startDate,
|
startAt: +startDate,
|
||||||
@ -67,7 +67,11 @@ export default function WebsiteChart({
|
|||||||
}, [data, startDate, endDate, unit]);
|
}, [data, startDate, endDate, unit]);
|
||||||
|
|
||||||
function handleCloseFilter(param) {
|
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) {
|
async function handleDateChange(value) {
|
||||||
@ -82,10 +86,6 @@ export default function WebsiteChart({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading icon="dots" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
|
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
|
||||||
@ -102,15 +102,11 @@ export default function WebsiteChart({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</WebsiteHeader>
|
</WebsiteHeader>
|
||||||
<StickyHeader
|
<FilterTags
|
||||||
className={styles.metrics}
|
params={{ url, referrer, os, browser, device, country }}
|
||||||
stickyClassName={styles.sticky}
|
onClick={handleCloseFilter}
|
||||||
enabled={stickyHeader}
|
/>
|
||||||
>
|
<StickyHeader stickyClassName={styles.sticky} enabled={stickyHeader}>
|
||||||
<FilterTags
|
|
||||||
params={{ url, referrer, os, browser, device, country }}
|
|
||||||
onClick={handleCloseFilter}
|
|
||||||
/>
|
|
||||||
<Row className={styles.header}>
|
<Row className={styles.header}>
|
||||||
<Column xs={12} sm={12} md={12} defaultSize={10}>
|
<Column xs={12} sm={12} md={12} defaultSize={10}>
|
||||||
<MetricsBar websiteId={websiteId} />
|
<MetricsBar websiteId={websiteId} />
|
||||||
|
@ -17,24 +17,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
min-height: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 0;
|
min-height: 90px;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky {
|
.sticky {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
margin: auto;
|
|
||||||
background: var(--base50);
|
background: var(--base50);
|
||||||
border-bottom: 1px solid var(--base300);
|
border-bottom: 1px solid var(--base300);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
width: inherit;
|
||||||
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import MenuButton from 'components/common/MenuButton';
|
import { Menu, Icon, Text, PopupTrigger, Popup, Item, Button } from 'react-basics';
|
||||||
import Gear from 'assets/gear.svg';
|
import Icons from 'components/icons';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
import { saveDashboard } from 'store/dashboard';
|
import { saveDashboard } from 'store/dashboard';
|
||||||
import { Icon } from 'react-basics';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' },
|
toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' },
|
||||||
@ -33,10 +33,18 @@ export default function DashboardSettingsButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuButton options={menuOptions} onSelect={handleSelect} hideLabel>
|
<PopupTrigger>
|
||||||
<Icon>
|
<Button>
|
||||||
<Gear />
|
<Icon>
|
||||||
</Icon>
|
<Icons.Edit />
|
||||||
</MenuButton>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { Row, Column } from 'react-basics';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { subMinutes, startOfMinute } from 'date-fns';
|
import { subMinutes, startOfMinute } from 'date-fns';
|
||||||
import firstBy from 'thenby';
|
import firstBy from 'thenby';
|
||||||
import Page from 'components/layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout';
|
|
||||||
import RealtimeChart from 'components/metrics/RealtimeChart';
|
import RealtimeChart from 'components/metrics/RealtimeChart';
|
||||||
import RealtimeLog from 'components/metrics/RealtimeLog';
|
import RealtimeLog from 'components/metrics/RealtimeLog';
|
||||||
import RealtimeHeader from 'components/metrics/RealtimeHeader';
|
import RealtimeHeader from 'components/metrics/RealtimeHeader';
|
||||||
@ -129,29 +129,27 @@ export default function RealtimeDashboard() {
|
|||||||
<div className={styles.chart}>
|
<div className={styles.chart}>
|
||||||
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
|
<RealtimeChart data={realtimeData} unit="minute" records={REALTIME_RANGE} />
|
||||||
</div>
|
</div>
|
||||||
<GridLayout>
|
<Row>
|
||||||
<GridRow>
|
<Column xs={12} lg={4}>
|
||||||
<GridColumn xs={12} lg={4}>
|
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
|
||||||
<RealtimeViews websiteId={websiteId} data={realtimeData} websites={websites} />
|
</Column>
|
||||||
</GridColumn>
|
<Column xs={12} lg={8}>
|
||||||
<GridColumn xs={12} lg={8}>
|
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
|
||||||
<RealtimeLog websiteId={websiteId} data={realtimeData} websites={websites} />
|
</Column>
|
||||||
</GridColumn>
|
</Row>
|
||||||
</GridRow>
|
<Row>
|
||||||
<GridRow>
|
<Column xs={12} lg={4}>
|
||||||
<GridColumn xs={12} lg={4}>
|
<DataTable
|
||||||
<DataTable
|
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
||||||
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
data={countries}
|
||||||
data={countries}
|
renderLabel={renderCountryName}
|
||||||
renderLabel={renderCountryName}
|
/>
|
||||||
/>
|
</Column>
|
||||||
</GridColumn>
|
<Column xs={12} lg={8}>
|
||||||
<GridColumn xs={12} lg={8}>
|
<WorldMap data={countries} />
|
||||||
<WorldMap data={countries} />
|
</Column>
|
||||||
</GridColumn>
|
</Row>
|
||||||
</GridRow>
|
|
||||||
</GridLayout>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,12 @@ import { Icons, Loading } from 'react-basics';
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import MenuLayout from 'components/layout/MenuLayout';
|
|
||||||
import Page from 'components/layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import WebsiteChart from 'components/metrics/WebsiteChart';
|
import WebsiteChart from 'components/metrics/WebsiteChart';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
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 styles from './WebsiteDetails.module.css';
|
||||||
import WebsiteTableView from './WebsiteTableView';
|
import WebsiteTableView from './WebsiteTableView';
|
||||||
import WebsiteMenuView from './WebsiteMenuView';
|
import WebsiteMenuView from './WebsiteMenuView';
|
||||||
@ -27,19 +26,6 @@ export default function WebsiteDetails({ websiteId }) {
|
|||||||
query: { view },
|
query: { view },
|
||||||
} = usePageQuery();
|
} = 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() {
|
function handleDataLoad() {
|
||||||
if (!chartLoaded) {
|
if (!chartLoaded) {
|
||||||
setTimeout(() => setChartLoaded(true), DEFAULT_ANIMATION_DURATION);
|
setTimeout(() => setChartLoaded(true), DEFAULT_ANIMATION_DURATION);
|
||||||
@ -54,7 +40,7 @@ export default function WebsiteDetails({ websiteId }) {
|
|||||||
domain={data?.domain}
|
domain={data?.domain}
|
||||||
onDataLoad={handleDataLoad}
|
onDataLoad={handleDataLoad}
|
||||||
showLink={false}
|
showLink={false}
|
||||||
stickyHeader
|
stickyHeader={true}
|
||||||
/>
|
/>
|
||||||
{!chartLoaded && <Loading icon="dots" />}
|
{!chartLoaded && <Loading icon="dots" />}
|
||||||
{chartLoaded && (
|
{chartLoaded && (
|
||||||
|
@ -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 Link from 'next/link';
|
||||||
|
import classNames from 'classnames';
|
||||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||||
import CountriesTable from 'components/metrics/CountriesTable';
|
import CountriesTable from 'components/metrics/CountriesTable';
|
||||||
import DevicesTable from 'components/metrics/DevicesTable';
|
import DevicesTable from 'components/metrics/DevicesTable';
|
||||||
@ -12,8 +14,8 @@ import ScreenTable from 'components/metrics/ScreenTable';
|
|||||||
import EventsTable from 'components/metrics/EventsTable';
|
import EventsTable from 'components/metrics/EventsTable';
|
||||||
import usePageQuery from 'hooks/usePageQuery';
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import Icons from 'components/icons';
|
import Icons from 'components/icons';
|
||||||
import { labels } from '../../messages';
|
import { labels } from 'components/messages';
|
||||||
import { useIntl } from 'react-intl';
|
import styles from './WebsiteMenuView.module.css';
|
||||||
|
|
||||||
const views = {
|
const views = {
|
||||||
url: PagesTable,
|
url: PagesTable,
|
||||||
@ -37,77 +39,83 @@ export default function WebsiteMenuView({ websiteId, websiteDomain }) {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
key: 'url',
|
||||||
label: formatMessage(labels.pages),
|
label: formatMessage(labels.pages),
|
||||||
value: resolve({ view: 'url' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'referrer',
|
||||||
label: formatMessage(labels.referrers),
|
label: formatMessage(labels.referrers),
|
||||||
value: resolve({ view: 'referrer' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'browser',
|
||||||
label: formatMessage(labels.browsers),
|
label: formatMessage(labels.browsers),
|
||||||
value: resolve({ view: 'browser' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'os',
|
||||||
label: formatMessage(labels.os),
|
label: formatMessage(labels.os),
|
||||||
value: resolve({ view: 'os' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'device',
|
||||||
label: formatMessage(labels.devices),
|
label: formatMessage(labels.devices),
|
||||||
value: resolve({ view: 'device' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'country',
|
||||||
label: formatMessage(labels.countries),
|
label: formatMessage(labels.countries),
|
||||||
value: resolve({ view: 'country' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'language',
|
||||||
label: formatMessage(labels.languages),
|
label: formatMessage(labels.languages),
|
||||||
value: resolve({ view: 'language' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'screen',
|
||||||
label: formatMessage(labels.screens),
|
label: formatMessage(labels.screens),
|
||||||
value: resolve({ view: 'screen' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'event',
|
||||||
label: formatMessage(labels.events),
|
label: formatMessage(labels.events),
|
||||||
value: resolve({ view: 'event' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'query',
|
||||||
label: formatMessage(labels.query),
|
label: formatMessage(labels.query),
|
||||||
value: resolve({ view: 'query' }),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DetailsComponent = views[view];
|
const DetailsComponent = views[view];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row className={styles.row}>
|
||||||
<Column>
|
<Column defaultSize={3} className={classNames(styles.col, styles.menu)}>
|
||||||
<Button>
|
<Link href={resolve({ view: undefined })}>
|
||||||
<Icon rotate={180}>
|
<a>
|
||||||
<Icons.ArrowRight />
|
<Flexbox justifyContent="center">
|
||||||
</Icon>
|
<Button variant="quiet">
|
||||||
{formatMessage(labels.back)}
|
<Icon rotate={180}>
|
||||||
</Button>
|
<Icons.ArrowRight />
|
||||||
<Menu items={items}>
|
</Icon>
|
||||||
{({ value, label }) => (
|
<Text>{formatMessage(labels.back)}</Text>
|
||||||
<Link href={resolve()}>
|
</Button>
|
||||||
<a>
|
</Flexbox>
|
||||||
<Item key={value}>{label}</Item>
|
</a>
|
||||||
</a>
|
</Link>
|
||||||
</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>
|
</Menu>
|
||||||
</Column>
|
</Column>
|
||||||
<Column>
|
<Column defaultSize={9} className={classNames(styles.col, styles.data)}>
|
||||||
<DetailsComponent
|
<DetailsComponent
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
websiteDomain={websiteDomain}
|
websiteDomain={websiteDomain}
|
||||||
height={500}
|
height={500}
|
||||||
limit={false}
|
limit={false}
|
||||||
animate={false}
|
animate={false}
|
||||||
showFilters
|
showFilters={true}
|
||||||
virtualize
|
virtualize={true}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
|
31
components/pages/websites/WebsiteMenuView.module.css
Normal file
31
components/pages/websites/WebsiteMenuView.module.css
Normal 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;
|
||||||
|
}
|
@ -9,6 +9,7 @@ import WorldMap from 'components/common/WorldMap';
|
|||||||
import CountriesTable from 'components/metrics/CountriesTable';
|
import CountriesTable from 'components/metrics/CountriesTable';
|
||||||
import EventsTable from 'components/metrics/EventsTable';
|
import EventsTable from 'components/metrics/EventsTable';
|
||||||
import EventsChart from 'components/metrics/EventsChart';
|
import EventsChart from 'components/metrics/EventsChart';
|
||||||
|
import styles from './WebsiteTableView.module.css';
|
||||||
|
|
||||||
export default function WebsiteTableView({ websiteId }) {
|
export default function WebsiteTableView({ websiteId }) {
|
||||||
const [countryData, setCountryData] = useState();
|
const [countryData, setCountryData] = useState();
|
||||||
@ -19,38 +20,38 @@ export default function WebsiteTableView({ websiteId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row>
|
<Row className={styles.row}>
|
||||||
<Column variant="two">
|
<Column className={styles.col} variant="two">
|
||||||
<PagesTable {...tableProps} />
|
<PagesTable {...tableProps} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column variant="two">
|
<Column className={styles.col} variant="two">
|
||||||
<ReferrersTable {...tableProps} />
|
<ReferrersTable {...tableProps} />
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row className={styles.row}>
|
||||||
<Column variant="three">
|
<Column className={styles.col} variant="three">
|
||||||
<BrowsersTable {...tableProps} />
|
<BrowsersTable {...tableProps} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column variant="three">
|
<Column className={styles.col} variant="three">
|
||||||
<OSTable {...tableProps} />
|
<OSTable {...tableProps} />
|
||||||
</Column>
|
</Column>
|
||||||
<Column variant="three">
|
<Column className={styles.col} variant="three">
|
||||||
<DevicesTable {...tableProps} />
|
<DevicesTable {...tableProps} />
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row className={styles.row}>
|
||||||
<Column xs={12} sm={12} md={12} defaultSize={8}>
|
<Column className={styles.col} xs={12} sm={12} md={12} defaultSize={8}>
|
||||||
<WorldMap data={countryData} />
|
<WorldMap data={countryData} />
|
||||||
</Column>
|
</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} />
|
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row className={styles.row}>
|
||||||
<Column xs={12} md={12} lg={4} defaultSize={4}>
|
<Column className={styles.col} xs={12} md={12} lg={4} defaultSize={4}>
|
||||||
<EventsTable {...tableProps} />
|
<EventsTable {...tableProps} />
|
||||||
</Column>
|
</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} />
|
<EventsChart websiteId={websiteId} />
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -1,8 +1,3 @@
|
|||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col {
|
.col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
27
hooks/useSticky.js
Normal file
27
hooks/useSticky.js
Normal 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 };
|
||||||
|
}
|
@ -23,6 +23,16 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
|
|||||||
export const REALTIME_RANGE = 30;
|
export const REALTIME_RANGE = 30;
|
||||||
export const REALTIME_INTERVAL = 3000;
|
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 = {
|
export const EVENT_TYPE = {
|
||||||
pageView: 1,
|
pageView: 1,
|
||||||
customEvent: 2,
|
customEvent: 2,
|
||||||
@ -103,8 +113,6 @@ export const EVENT_COLORS = [
|
|||||||
'#ffec16',
|
'#ffec16',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FILTER_IGNORED = Symbol.for('filter-ignored');
|
|
||||||
|
|
||||||
export const DOMAIN_REGEX =
|
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})$/;
|
/^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63})$/;
|
||||||
|
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
export const urlFilter = data => {
|
export const urlFilter = data => {
|
||||||
const isValidUrl = url => {
|
const isValidUrl = url => {
|
||||||
return url !== '' && url !== null && !url.startsWith('#');
|
return url !== '' && url !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanUrl = url => {
|
const cleanUrl = url => {
|
||||||
try {
|
try {
|
||||||
const { pathname, search } = new URL(url, location.origin);
|
const { pathname } = new URL(url, location.origin);
|
||||||
|
|
||||||
if (search.startsWith('?')) {
|
|
||||||
return `${pathname}${search}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname;
|
return pathname;
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -39,8 +39,6 @@ export interface Share {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Empty {}
|
|
||||||
|
|
||||||
export interface WebsiteActive {
|
export interface WebsiteActive {
|
||||||
x: number;
|
x: number;
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
"@prisma/client": "4.9.0",
|
"@prisma/client": "4.9.0",
|
||||||
"@tanstack/react-query": "^4.16.1",
|
"@tanstack/react-query": "^4.16.1",
|
||||||
"@umami/prisma-client": "^0.2.0",
|
"@umami/prisma-client": "^0.2.0",
|
||||||
"@umami/redis-client": "^0.1.0",
|
"@umami/redis-client": "^0.2.0",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"chart.js": "^2.9.4",
|
"chart.js": "^2.9.4",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
@ -92,9 +92,8 @@
|
|||||||
"next-basics": "^0.26.0",
|
"next-basics": "^0.26.0",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prop-types": "^15.7.2",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-basics": "^0.64.0",
|
"react-basics": "^0.66.0",
|
||||||
"react-beautiful-dnd": "^13.1.0",
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-intl": "^5.24.7",
|
"react-intl": "^5.24.7",
|
||||||
@ -103,7 +102,6 @@
|
|||||||
"react-tooltip": "^4.2.21",
|
"react-tooltip": "^4.2.21",
|
||||||
"react-use-measure": "^2.0.4",
|
"react-use-measure": "^2.0.4",
|
||||||
"react-window": "^1.8.6",
|
"react-window": "^1.8.6",
|
||||||
"redis": "^4.6.2",
|
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
"semver": "^7.3.6",
|
"semver": "^7.3.6",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
const { Resolver } = require('dns').promises;
|
|
||||||
import isbot from 'isbot';
|
import isbot from 'isbot';
|
||||||
import ipaddr from 'ipaddr.js';
|
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 { savePageView, saveEvent } from 'queries';
|
||||||
import { useCors, useSession } from 'lib/middleware';
|
import { useCors, useSession } from 'lib/middleware';
|
||||||
import { getJsonBody, getIpAddress } from 'lib/detect';
|
import { getJsonBody, getIpAddress } from 'lib/detect';
|
||||||
import { secret } from 'lib/crypto';
|
import { secret } from 'lib/crypto';
|
||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Resolver } from 'dns/promises';
|
||||||
|
|
||||||
export interface NextApiRequestCollect extends NextApiRequest {
|
export interface NextApiRequestCollect extends NextApiRequest {
|
||||||
session: {
|
session: {
|
||||||
@ -26,7 +26,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
|
|||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
|
|
||||||
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
|
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
|
||||||
return unauthorized(res);
|
return ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, payload } = getJsonBody(req);
|
const { type, payload } = getJsonBody(req);
|
||||||
@ -61,7 +61,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
|
|||||||
.map(n => resolver.resolve4(n.trim()).catch(() => {}));
|
.map(n => resolver.resolve4(n.trim()).catch(() => {}));
|
||||||
|
|
||||||
await Promise.all(promises).then(resolvedIps => {
|
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
31
pages/api/me/websites.ts
Normal 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);
|
||||||
|
};
|
@ -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 { useAuth, useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createWebsite, getUserWebsites } from 'queries';
|
import { getUserWebsites } from 'queries';
|
||||||
|
|
||||||
export interface WebsitesRequestQuery {}
|
|
||||||
|
|
||||||
export interface WebsitesRequestBody {
|
export interface WebsitesRequestBody {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
shareId: string;
|
shareId: string;
|
||||||
teamId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
|
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) => {
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const {
|
const { user } = req.auth;
|
||||||
user: { id: userId },
|
const { id: userId } = req.query;
|
||||||
} = req.auth;
|
|
||||||
const { id } = req.query;
|
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
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);
|
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);
|
return methodNotAllowed(res);
|
||||||
};
|
};
|
||||||
|
@ -90,15 +90,15 @@ export default async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (type === 'language') {
|
if (type === 'language') {
|
||||||
let combined = {};
|
const combined = {};
|
||||||
|
|
||||||
for (let { x, y } of data) {
|
for (const { x, y } of data) {
|
||||||
x = String(x).toLowerCase().split('-')[0];
|
const key = String(x).toLowerCase().split('-')[0];
|
||||||
|
|
||||||
if (!combined[x]) {
|
if (!combined[key]) {
|
||||||
combined[x] = { x, y };
|
combined[key] = { x, y };
|
||||||
} else {
|
} else {
|
||||||
combined[x].y += y;
|
combined[key].y += y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,8 +6,6 @@ import { NextApiResponse } from 'next';
|
|||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createWebsite, getUserWebsites } from 'queries';
|
import { createWebsite, getUserWebsites } from 'queries';
|
||||||
|
|
||||||
export interface WebsitesRequestQuery {}
|
|
||||||
|
|
||||||
export interface WebsitesRequestBody {
|
export interface WebsitesRequestBody {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
@ -16,7 +14,7 @@ export interface WebsitesRequestBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
|
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) => {
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
|
49
yarn.lock
49
yarn.lock
@ -1930,11 +1930,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.1.0.tgz#64e310ddee72010676e14296076329e594a1f6c7"
|
resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.1.0.tgz#64e310ddee72010676e14296076329e594a1f6c7"
|
||||||
integrity sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==
|
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":
|
"@redis/client@1.4.2":
|
||||||
version "1.4.2"
|
version "1.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.4.2.tgz#2a3f5e98bc33b7b979390442e6e08f96e57fabdd"
|
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.4.2.tgz#2a3f5e98bc33b7b979390442e6e08f96e57fabdd"
|
||||||
@ -1944,15 +1939,6 @@
|
|||||||
generic-pool "3.9.0"
|
generic-pool "3.9.0"
|
||||||
yallist "4.0.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":
|
"@redis/graph@1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.0.tgz#cc2b82e5141a29ada2cce7d267a6b74baa6dd519"
|
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"
|
resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.1.0.tgz#7abb18d431f27ceafe6bcb4dd83a3fa67e9ab4df"
|
||||||
integrity sha512-NyFZEVnxIJEybpy+YskjgOJRNsfTYqaPbK/Buv6W2kmFNaRk85JiqjJZA5QkRmWvGbyQYwoO5QfDi2wHskKrQQ==
|
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":
|
"@redis/time-series@1.0.4":
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717"
|
resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717"
|
||||||
@ -2435,10 +2416,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
debug "^4.3.4"
|
debug "^4.3.4"
|
||||||
|
|
||||||
"@umami/redis-client@^0.1.0":
|
"@umami/redis-client@^0.2.0":
|
||||||
version "0.1.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.1.0.tgz#69599b1243406a3aef83927bb8655a3ef4c0c031"
|
resolved "https://registry.yarnpkg.com/@umami/redis-client/-/redis-client-0.2.0.tgz#bdb1cd8b5c99afc5230621f19296c6d3559d68af"
|
||||||
integrity sha512-XCqCdc3xA5KDOorIUvOVlz+/F/SEyL9cKWfCCGYYZix1b9yFo+5BzM0C0q7Yu/qV+1jYd+viNsBQQSM6a8sfjg==
|
integrity sha512-TONWhkuC//K2hRo3Psk7FHsuvu3XkQIYMY62/CERPtlIJz4Ac7DqsmYw4jO9/RkljA9XLl/5u+OggD4ARhMV8A==
|
||||||
dependencies:
|
dependencies:
|
||||||
debug "^4.3.4"
|
debug "^4.3.4"
|
||||||
redis "^4.5.1"
|
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"
|
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz#10ccb9ded0729464b6d2e7d714b100a2d1259d43"
|
||||||
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
|
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"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
|
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
|
||||||
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
||||||
@ -6695,10 +6676,10 @@ rc@^1.2.7:
|
|||||||
minimist "^1.2.0"
|
minimist "^1.2.0"
|
||||||
strip-json-comments "~2.0.1"
|
strip-json-comments "~2.0.1"
|
||||||
|
|
||||||
react-basics@^0.64.0:
|
react-basics@^0.66.0:
|
||||||
version "0.64.0"
|
version "0.66.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.64.0.tgz#b921dab7e437db6655f033cae15b8c963b93b7b2"
|
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.66.0.tgz#c9d756936e9e996cbd70fb83e164fc673932fa0d"
|
||||||
integrity sha512-MY/F5+VBqqi+Hx58PdRONoeu3W0sitPOFbvAGxiM9vpajQL1DD//0Xgl/MahW8sIDbMy00lFghouex5JS93C8Q==
|
integrity sha512-VKndXhIyWb/e080/TUXB2WnEfF/DSFSExer8AcxXJk58Illbg6bqMLjGuP2gtJ/dvr5skSPqm8b9gLVFU9C1ig==
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.3.1"
|
classnames "^2.3.1"
|
||||||
date-fns "^2.29.3"
|
date-fns "^2.29.3"
|
||||||
@ -6932,18 +6913,6 @@ redis@^4.5.1:
|
|||||||
"@redis/search" "1.1.0"
|
"@redis/search" "1.1.0"
|
||||||
"@redis/time-series" "1.0.4"
|
"@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:
|
redux@^4.0.0, redux@^4.0.4:
|
||||||
version "4.2.0"
|
version "4.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
|
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user