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

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

View File

@ -20,7 +20,7 @@
"next" "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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import classNames from 'classnames'; import 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;

View File

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

View File

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

View File

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

View File

@ -1,48 +1,32 @@
import { useState, useRef, useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useMeasure, useCombinedRefs } from 'react-basics';
import classNames from 'classnames'; import 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>
); );
} }

View File

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

View File

@ -1,4 +1,4 @@
import { Button, Icon, Icons, PopupTrigger, Tooltip } from 'react-basics'; import { Button, Icon, Icons, Tooltip } from 'react-basics';
import Link from 'next/link'; import 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>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import { useRouter } from 'next/router';
import Script from 'next/script'; import 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`} />}

View File

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

View File

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

View File

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

View File

@ -25,9 +25,14 @@
} }
.icon { .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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 && (

View File

@ -1,5 +1,7 @@
import { Row, Column, Menu, Item, Icon, Text, Button } from 'react-basics'; import { Row, Column, Menu, Item, Icon, Button, Flexbox, Text } from 'react-basics';
import { useIntl } from 'react-intl';
import Link from 'next/link'; import 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>

View File

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

View File

@ -9,6 +9,7 @@ import WorldMap from 'components/common/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable'; import 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>

View File

@ -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
View File

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

View File

@ -23,6 +23,16 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
export const REALTIME_RANGE = 30; export const REALTIME_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})$/;

View File

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

View File

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

View File

@ -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",

View File

@ -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
View File

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

View File

@ -1,63 +1,34 @@
import { Prisma } from '@prisma/client';
import { canCreateWebsite } from 'lib/auth';
import { uuid } from 'lib/crypto';
import { useAuth, useCors } from 'lib/middleware'; import { 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);
}; };

View File

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

View File

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

View File

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