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