# Conflicts:
#	lib/middleware.ts
#	pages/api/users/[id]/index.ts
#	pages/api/users/index.ts
#	pages/api/websites/[id]/active.ts
#	pages/api/websites/[id]/eventdata.ts
#	pages/api/websites/[id]/events.ts
#	pages/api/websites/[id]/index.ts
#	pages/api/websites/[id]/metrics.ts
#	pages/api/websites/[id]/pageviews.ts
#	pages/api/websites/[id]/reset.ts
#	pages/api/websites/[id]/stats.ts
#	yarn.lock
This commit is contained in:
Mike Cao 2022-12-31 13:54:44 -08:00
commit f3879c92e1
212 changed files with 2642 additions and 2841 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Mike Cao <mike@mikecao.com>
Copyright (c) 2022 Umami Software, Inc. <hello@umami.is>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -6,7 +6,7 @@
"repository": "https://github.com/umami-software/umami",
"addons": ["heroku-postgresql"],
"env": {
"HASH_SALT": {
"APP_SECRET": {
"description": "Used to generate unique values for your installation",
"required": true,
"generator": "secret"

1
assets/buoy.svg Normal file
View File

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="m457.65 54.783-.441-.441c-14.686-14.656-37.156-16.922-54.325-6.828C359.083 16.393 308.546 0 256 0c-52.545 0-103.082 16.393-146.884 47.515-17.168-10.094-39.635-7.83-54.314 6.817l-.46.46c-14.656 14.689-16.92 37.158-6.827 54.325C16.393 152.918 0 203.455 0 256s16.393 103.082 47.515 146.884c-10.093 17.167-7.828 39.637 6.83 54.328l.446.446c14.622 14.59 37.075 16.971 54.326 6.828C152.919 495.607 203.455 512 256 512c52.546 0 103.082-16.393 146.883-47.514 17.272 10.155 39.721 7.745 54.329-6.831l.445-.445c14.657-14.689 16.922-37.158 6.828-54.326C495.606 359.082 512 308.545 512 256s-16.394-103.081-47.515-146.884c10.094-17.168 7.828-39.638-6.835-54.333zm-42.556 20.915c5.798-5.796 15.184-5.849 20.919-.126l.408.409c5.73 5.743 5.678 15.13-.118 20.925l-74.876 74.875a136.212 136.212 0 0 0-21.209-21.209zM361 256c0 57.897-47.103 105-105 105s-105-47.103-105-105 47.103-105 105-105 105 47.103 105 105zM256 30c44.114 0 86.687 13.18 124.112 38.253l-65.939 65.939C296.548 125.74 276.818 121 256 121s-40.548 4.74-58.174 13.191l-65.938-65.939C169.313 43.18 211.886 30 256 30zM75.572 75.988l.409-.409c5.743-5.73 15.13-5.677 20.926.118l74.875 74.876a136.212 136.212 0 0 0-21.209 21.209L75.697 96.906c-5.795-5.796-5.848-15.183-.125-20.918zM30 256c0-44.113 13.18-86.687 38.253-124.112l65.938 65.939C125.74 215.452 121 235.182 121 256s4.74 40.548 13.191 58.174l-65.938 65.939C43.18 342.687 30 300.113 30 256zm66.906 180.302c-5.796 5.796-15.182 5.849-20.941.103l-.386-.385c-5.73-5.743-5.677-15.13.118-20.926l74.875-74.875a136.168 136.168 0 0 0 21.209 21.209zM256 482c-44.114 0-86.687-13.18-124.112-38.253l65.938-65.939C215.452 386.26 235.182 391 256 391s40.548-4.74 58.174-13.191l65.939 65.939C342.687 468.82 300.114 482 256 482zm180.423-45.983-.404.404c-5.742 5.73-15.128 5.677-20.925-.119l-74.875-74.875a136.168 136.168 0 0 0 21.209-21.209l74.876 74.876c5.795 5.796 5.847 15.183.119 20.923zM482 256c0 44.113-13.18 86.687-38.253 124.112l-65.938-65.938C386.26 296.548 391 276.818 391 256s-4.74-40.548-13.191-58.174l65.938-65.938C468.82 169.314 482 211.887 482 256z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

1
assets/card.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512pt" viewBox="0 -76 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M452 0H60C26.914 0 0 26.914 0 60v240c0 33.086 26.914 60 60 60h392c33.086 0 60-26.914 60-60V60c0-33.086-26.914-60-60-60zM60 40h392c11.027 0 20 8.973 20 20v20H40V60c0-11.027 8.973-20 20-20zm392 280H60c-11.027 0-20-8.973-20-20V120h432v180c0 11.027-8.973 20-20 20zm-10-55c0 13.809-11.191 25-25 25s-25-11.191-25-25 11.191-25 25-25 25 11.191 25 25zm-70 0c0 13.809-11.191 25-25 25s-25-11.191-25-25 11.191-25 25-25 25 11.191 25 25zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 538 B

1
assets/users.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 477.869 477.869" style="enable-background:new 0 0 477.869 477.869" xml:space="preserve"><path d="M387.415 233.496c48.976-44.029 52.987-119.424 8.958-168.4C355.991 20.177 288.4 12.546 239.02 47.332c-53.83-37.99-128.264-25.149-166.254 28.68-34.859 49.393-27.259 117.054 17.689 157.483C34.606 262.935-.251 320.976.002 384.108v51.2c0 9.426 7.641 17.067 17.067 17.067h443.733c9.426 0 17.067-7.641 17.067-17.067v-51.2c.252-63.132-34.605-121.173-90.454-150.612zM307.201 59.842c47.062-.052 85.256 38.057 85.309 85.119.037 33.564-19.631 64.023-50.237 77.799-1.314.597-2.628 1.143-3.959 1.707a83.66 83.66 0 0 1-12.988 4.045c-.853.188-1.707.29-2.577.461a85.366 85.366 0 0 1-15.019 1.519c-2.27 0-4.557-.171-6.827-.375-.853 0-1.707 0-2.56-.171a86.219 86.219 0 0 1-27.904-8.226c-.324-.154-.7-.137-1.024-.273-1.707-.819-3.413-1.536-4.932-2.458.137-.171.222-.358.358-.529a119.721 119.721 0 0 0 18.278-33.297l.529-1.434a120.381 120.381 0 0 0 4.523-17.562c.154-.87.273-1.707.41-2.645.987-6.067 1.506-12.2 1.553-18.347a120.041 120.041 0 0 0-1.553-18.313c-.137-.887-.256-1.707-.41-2.645a120.414 120.414 0 0 0-4.523-17.562l-.529-1.434a119.747 119.747 0 0 0-18.278-33.297c-.137-.171-.222-.358-.358-.529a84.787 84.787 0 0 1 42.718-11.553zM85.335 145.176c-.121-47.006 37.886-85.21 84.892-85.331a85.112 85.112 0 0 1 59.134 23.686c.99.956 1.963 1.911 2.918 2.901a87.748 87.748 0 0 1 8.09 9.813c.751 1.058 1.434 2.185 2.133 3.277a83.951 83.951 0 0 1 6.263 11.52c.427.973.751 1.963 1.126 2.935a83.422 83.422 0 0 1 4.233 13.653c.12.512.154 1.024.256 1.553a80.338 80.338 0 0 1 0 32.119c-.102.529-.137 1.041-.256 1.553a83.228 83.228 0 0 1-4.233 13.653c-.375.973-.7 1.963-1.126 2.935a84.251 84.251 0 0 1-6.263 11.503c-.7 1.092-1.382 2.219-2.133 3.277a87.549 87.549 0 0 1-8.09 9.813c-.956.99-1.929 1.946-2.918 2.901a85.187 85.187 0 0 1-23.569 15.906 49.35 49.35 0 0 1-4.198 1.707 85.839 85.839 0 0 1-12.663 3.925c-1.075.239-2.185.375-3.277.563a84.67 84.67 0 0 1-14.046 1.417h-1.877a84.563 84.563 0 0 1-14.046-1.417c-1.092-.188-2.202-.324-3.277-.563a85.802 85.802 0 0 1-12.663-3.925c-1.417-.563-2.816-1.143-4.198-1.707-30.534-13.786-50.173-44.166-50.212-77.667zm221.866 273.066H34.135v-34.133c-.25-57.833 36.188-109.468 90.76-128.614a119.092 119.092 0 0 0 91.546 0 137.138 137.138 0 0 1 16.623 7.356c3.55 1.826 6.827 3.908 10.24 6.007 2.219 1.382 4.471 2.731 6.605 4.25 3.294 2.338 6.4 4.881 9.455 7.492 1.963 1.707 3.908 3.413 5.751 5.12 2.816 2.662 5.461 5.478 8.004 8.363a134.465 134.465 0 0 1 5.291 6.383 132.594 132.594 0 0 1 6.349 8.823c1.707 2.56 3.226 5.222 4.727 7.885 1.707 2.935 3.277 5.871 4.71 8.926 1.434 3.055 2.697 6.4 3.925 9.66 1.075 2.833 2.219 5.649 3.106 8.533 1.195 3.959 2.031 8.055 2.867 12.151.512 2.423 1.178 4.796 1.553 7.253a141.153 141.153 0 0 1 1.553 20.412v34.133zm136.534 0h-102.4v-34.133c0-5.342-.307-10.633-.785-15.872-.137-1.536-.375-3.055-.546-4.591-.461-3.772-.99-7.509-1.707-11.213a246.936 246.936 0 0 0-.973-4.762c-.819-3.8-1.769-7.566-2.85-11.298-.358-1.229-.683-2.475-1.058-3.686a169.105 169.105 0 0 0-20.565-43.127l-.666-.973a168.958 168.958 0 0 0-9.404-12.646l-.119-.154a154.895 154.895 0 0 0-11.008-12.237h.7a120.8 120.8 0 0 0 14.524 1.024h.939c4.496-.039 8.985-.33 13.449-.87 1.399-.171 2.782-.427 4.181-.649a117.43 117.43 0 0 0 10.752-2.167c1.007-.256 2.031-.495 3.055-.785a116.211 116.211 0 0 0 13.653-4.642c54.612 19.127 91.083 70.785 90.829 128.649v34.132z"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

1
assets/website.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999" xml:space="preserve"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,63 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import Icon from './Icon';
import styles from './Button.module.css';
function Button({
type = 'button',
icon,
size,
variant,
children,
className,
tooltip,
tooltipId,
disabled,
iconRight,
onClick = () => {},
...props
}) {
return (
<button
data-tip={tooltip}
data-effect="solid"
data-for={tooltipId}
data-offset={JSON.stringify({ left: 10 })}
type={type}
className={classNames(styles.button, className, {
[styles.large]: size === 'large',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
[styles.action]: variant === 'action',
[styles.danger]: variant === 'danger',
[styles.light]: variant === 'light',
[styles.iconRight]: iconRight,
})}
disabled={disabled}
onClick={!disabled ? onClick : null}
{...props}
>
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
{children && <div className={styles.label}>{children}</div>}
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
</button>
);
}
Button.propTypes = {
type: PropTypes.oneOf(['button', 'submit', 'reset']),
icon: PropTypes.node,
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
variant: PropTypes.oneOf(['action', 'danger', 'light']),
children: PropTypes.node,
className: PropTypes.string,
tooltip: PropTypes.node,
tooltipId: PropTypes.string,
disabled: PropTypes.bool,
iconRight: PropTypes.bool,
onClick: PropTypes.func,
};
export default Button;

View File

@ -1,102 +0,0 @@
.button {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-md);
color: var(--base900);
background: var(--base100);
padding: 8px 16px;
border-radius: 4px;
border: 0;
outline: none;
cursor: pointer;
position: relative;
}
.button:hover {
background: var(--base200);
}
.button:active {
color: var(--base900);
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
}
.large {
font-size: var(--font-size-lg);
}
.small {
font-size: var(--font-size-sm);
}
.xsmall {
font-size: var(--font-size-xs);
}
.action,
.action:active {
color: var(--base50);
background: var(--base900);
}
.action:hover {
background: var(--base800);
}
.danger,
.danger:active {
color: var(--base50);
background: var(--red500);
}
.danger:hover {
background: var(--red400);
}
.light,
.light:active {
color: var(--base900);
background: transparent;
}
.light:hover {
background: inherit;
}
.button .icon + * {
margin-left: 10px;
}
.button.iconRight .icon {
order: 1;
margin-left: 10px;
}
.button.iconRight .icon + * {
margin: 0;
}
.button:disabled {
cursor: default;
color: var(--base500);
background: var(--base75);
}
.button:disabled:active {
color: var(--base500);
}
.button:disabled:hover {
background: var(--base75);
}
.button.light:disabled {
background: var(--base50);
}

View File

@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Button from './Button';
import styles from './ButtonGroup.module.css';
function ButtonGroup({ items = [], selectedItem, className, size, icon, onClick = () => {} }) {
return (
<div className={classNames(styles.group, className)}>
{items.map(item => {
const { label, value } = item;
return (
<Button
key={value}
className={classNames(styles.button, { [styles.selected]: selectedItem === value })}
size={size}
icon={icon}
onClick={() => onClick(value)}
>
{label}
</Button>
);
})}
</div>
);
}
ButtonGroup.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any.isRequired,
}),
),
selectedItem: PropTypes.any,
className: PropTypes.string,
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
icon: PropTypes.node,
onClick: PropTypes.func,
};
export default ButtonGroup;

View File

@ -1,31 +0,0 @@
.group {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--base500);
}
.group .button {
border-radius: 0;
color: var(--base800);
background: var(--base50);
border-left: 1px solid var(--base500);
padding: 4px 8px;
}
.group .button:first-child {
border: 0;
}
.group .button:hover {
background: var(--base100);
}
.group .button + .button {
margin: 0;
}
.group .button.selected {
color: var(--base900);
font-weight: 600;
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import classNames from 'classnames';
import {
startOfWeek,
@ -16,15 +16,14 @@ import {
isBefore,
isAfter,
} from 'date-fns';
import Button from './Button';
import { Button, Icon } from 'react-basics';
import { chunkArray } from 'next-basics';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date';
import { chunk } from 'lib/array';
import { getDateLocale } from 'lib/lang';
import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg';
import styles from './Calendar.module.css';
import Icon from './Icon';
export default function Calendar({ date, minDate, maxDate, onChange }) {
const { locale } = useLocale();
@ -61,14 +60,18 @@ export default function Calendar({ date, minDate, maxDate, onChange }) {
onClick={toggleMonthSelect}
>
{month}
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
<Icon className={styles.icon} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</div>
<div
className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect}
>
{year}
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
<Icon className={styles.icon} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</div>
</div>
<div className={styles.body}>
@ -139,7 +142,7 @@ const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
</tr>
</thead>
<tbody>
{chunk(days, 7).map((week, i) => (
{chunkArray(days, 7).map((week, i) => (
<tr key={i}>
{week.map((day, j) => {
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
@ -178,7 +181,7 @@ const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
return (
<table>
<tbody>
{chunk(months, 3).map((row, i) => (
{chunkArray(months, 3).map((row, i) => (
<tr key={i}>
{row.map((month, j) => {
const disabled =
@ -230,17 +233,20 @@ const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
<div className={styles.pager}>
<div className={styles.left}>
<Button
icon={<Chevron />}
size="small"
onClick={handlePrevClick}
disabled={years[0] <= minYear}
variant="light"
/>
>
<Icon>
<Chevron />
</Icon>
</Button>
</div>
<div className={styles.middle}>
<table>
<tbody>
{chunk(years, 5).map((row, i) => (
{chunkArray(years, 5).map((row, i) => (
<tr key={i}>
{row.map((n, j) => (
<td
@ -261,12 +267,15 @@ const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
</div>
<div className={styles.right}>
<Button
icon={<Chevron />}
size="small"
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
variant="light"
/>
>
<Icon>
<Chevron />
</Icon>
</Button>
</div>
</div>
);

View File

@ -1,39 +0,0 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import Icon from 'components/common/Icon';
import Check from 'assets/check.svg';
import styles from './Checkbox.module.css';
function Checkbox({ name, value, label, onChange }) {
const ref = useRef();
const onClick = () => ref.current.click();
return (
<div className={styles.container}>
<div className={styles.checkbox} onClick={onClick}>
{value && <Icon icon={<Check />} size="small" />}
</div>
<label className={styles.label} htmlFor={name} onClick={onClick}>
{label}
</label>
<input
ref={ref}
className={styles.input}
type="checkbox"
name={name}
defaultChecked={value}
onChange={onChange}
/>
</div>
);
}
Checkbox.propTypes = {
name: PropTypes.string,
value: PropTypes.any,
label: PropTypes.node,
onChange: PropTypes.func,
};
export default Checkbox;

View File

@ -1,30 +0,0 @@
.container {
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.checkbox {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
border: 1px solid var(--base500);
border-radius: 4px;
}
.label {
margin-left: 10px;
user-select: none; /* disable text selection when clicking to toggle the checkbox */
}
.input {
position: absolute;
visibility: hidden;
height: 0;
width: 0;
bottom: 100%;
right: 100%;
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';
import Button from './Button';
import { Button } from 'react-basics';
import { FormattedMessage } from 'react-intl';
const defaultText = (

View File

@ -1,14 +1,13 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { endOfYear, isSameDay } from 'date-fns';
import Modal from './Modal';
import DropDown from './DropDown';
import Calendar from 'assets/calendar-alt.svg';
import DatePickerForm from 'components/forms/DatePickerForm';
import { endOfYear, isSameDay } from 'date-fns';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date';
import Calendar from 'assets/calendar-alt.svg';
import Icon from './Icon';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Icon, Modal } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import DropDown from './DropDown';
export const filterOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
@ -120,7 +119,9 @@ const CustomRange = ({ startDate, endDate, onClick }) => {
return (
<>
<Icon icon={<Calendar />} className="mr-2" onClick={handleClick} />
<Icon className="mr-2" onClick={handleClick}>
<Calendar />
</Icon>
{dateFormat(startDate, 'd LLL y', locale)}
{!isSameDay(startDate, endDate) && `${dateFormat(endDate, 'd LLL y', locale)}`}
</>

View File

@ -1,26 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Dot.module.css';
function Dot({ color, size, className }) {
return (
<div className={styles.wrapper}>
<div
style={{ background: color }}
className={classNames(styles.dot, className, {
[styles.small]: size === 'small',
[styles.large]: size === 'large',
})}
/>
</div>
);
}
Dot.propTypes = {
color: PropTypes.string,
size: PropTypes.oneOf(['small', 'large']),
className: PropTypes.string,
};
export default Dot;

View File

@ -1,22 +0,0 @@
.wrapper {
background: var(--base50);
margin-right: 10px;
border-radius: 100%;
}
.dot {
background: var(--green400);
width: 10px;
height: 10px;
border-radius: 100%;
}
.dot.small {
width: 8px;
height: 8px;
}
.dot.large {
width: 16px;
height: 16px;
}

View File

@ -1,11 +1,11 @@
import React, { useState, useRef } from 'react';
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Menu from './Menu';
import useDocumentClick from 'hooks/useDocumentClick';
import Chevron from 'assets/chevron-down.svg';
import styles from './Dropdown.module.css';
import Icon from './Icon';
import { Icon } from 'react-basics';
function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) {
const [showMenu, setShowMenu] = useState(false);
@ -33,7 +33,9 @@ function DropDown({ value, className, menuClassName, options = [], onChange = ()
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}>
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div>
<Icon icon={<Chevron />} className={styles.icon} size="small" />
<Icon className={styles.icon} size="small">
<Chevron />
</Icon>
</div>
{showMenu && (
<Menu

View File

@ -1,15 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'components/common/Icon';
import { Icon, Flexbox } from 'react-basics';
import Logo from 'assets/logo.svg';
import styles from './EmptyPlaceholder.module.css';
function EmptyPlaceholder({ msg, children }) {
return (
<div className={styles.placeholder}>
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
<Icon className={styles.icon} size="xl">
<Logo />
</Icon>
<h2 className={styles.msg}>{msg}</h2>
{children}
<Flexbox justifyContent="center" alignItems="center">
{children}
</Flexbox>
</div>
);
}

View File

@ -1,13 +1,14 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from './Icon';
import Exclamation from 'assets/exclamation-triangle.svg';
import { FormattedMessage } from 'react-intl';
import styles from './ErrorMessage.module.css';
import { Icon } from 'react-basics';
export default function ErrorMessage() {
return (
<div className={styles.error}>
<Icon icon={<Exclamation />} className={styles.icon} size="large" />
<Icon className={styles.icon} size="large">
<Exclamation />
</Icon>
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
</div>
);

View File

@ -1,10 +1,9 @@
import List from 'assets/list-ul.svg';
import Modal from 'components/common/Modal';
import EventDataForm from 'components/forms/EventDataForm';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Button, Icon, Modal } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import Button from './Button';
import EventDataForm from 'components/forms/EventDataForm';
import styles from './EventDataButton.module.css';
function EventDataButton({ websiteId }) {
@ -23,13 +22,15 @@ function EventDataButton({ websiteId }) {
return (
<>
<Button
icon={<List />}
tooltip={<FormattedMessage id="label.event-data" defaultMessage="Event" />}
tooltipId="button-event"
size="small"
onClick={handleClick}
className={styles.button}
>
<Icon>
<List />
</Icon>
Event Data
</Button>
{showEventData && (

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Favicon.module.css';

View File

@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import ButtonLayout from 'components/layout/ButtonLayout';
import ButtonGroup from './ButtonGroup';
import { ButtonGroup } from 'react-basics';
function FilterButtons({ buttons, selected, onClick }) {
return (

View File

@ -1,10 +1,9 @@
import React from 'react';
import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'hooks/usePageQuery';
import External from 'assets/arrow-up-right-from-square.svg';
import Icon from './Icon';
import { Icon } from 'react-basics';
import styles from './FilterLink.module.css';
export default function FilterLink({ id, value, label, externalUrl }) {
@ -26,7 +25,9 @@ export default function FilterLink({ id, value, label, externalUrl }) {
</Link>
{externalUrl && (
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon icon={<External />} className={styles.icon} />
<Icon className={styles.icon}>
<External />
</Icon>
</a>
)}
</div>

View File

@ -1,4 +1,4 @@
import Button from 'components/common/Button';
import { Button, Icon } from 'react-basics';
import XMark from 'assets/xmark.svg';
import Bars from 'assets/bars.svg';
import { useState } from 'react';
@ -33,11 +33,9 @@ export default function HamburgerButton() {
return (
<>
<Button
className={styles.button}
icon={active ? <XMark /> : <Bars />}
onClick={handleClick}
/>
<Button className={styles.button} onClick={handleClick}>
<Icon>{active ? <XMark /> : <Bars />}</Icon>
</Button>
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
</>
);

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Icon.module.css';

View File

@ -1,8 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import NextLink from 'next/link';
import Icon from './Icon';
import { Icon } from 'react-basics';
import styles from './Link.module.css';
function Link({ className, icon, children, size, iconRight, onClick, ...props }) {

View File

@ -1,21 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Loading.module.css';
function Loading({ className, overlay = false }) {
return (
<div className={classNames(styles.loading, { [styles.overlay]: overlay }, className)}>
<div />
<div />
<div />
</div>
);
}
Loading.propTypes = {
className: PropTypes.string,
overlay: PropTypes.bool,
};
export default Loading;

View File

@ -1,55 +0,0 @@
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
.loading {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
.loading.overlay {
height: 100%;
width: 100%;
z-index: 10;
background: var(--base400);
opacity: 0.4;
}
.loading div {
width: 10px;
height: 10px;
border-radius: 100%;
background: var(--base400);
animation: blink 1.4s infinite;
animation-fill-mode: both;
}
.loading.overlay div {
background: var(--base900);
}
.loading div + div {
margin-left: 10px;
}
.loading div:nth-child(2) {
animation-delay: 0.2s;
}
.loading div:nth-child(3) {
animation-delay: 0.4s;
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Menu.module.css';

View File

@ -1,13 +1,13 @@
import React, { useState, useRef } from 'react';
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Menu from 'components/common/Menu';
import Button from 'components/common/Button';
import useDocumentClick from 'hooks/useDocumentClick';
import styles from './MenuButton.module.css';
import { Button } from 'react-basics';
function MenuButton({
icon,
children,
value,
options,
buttonClassName,
@ -41,7 +41,6 @@ function MenuButton({
return (
<div className={styles.container} ref={ref}>
<Button
icon={icon}
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
onClick={toggleMenu}
variant={buttonVariant}
@ -49,6 +48,7 @@ function MenuButton({
{!hideLabel && (
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
)}
{children}
</Button>
{showMenu && (
<Menu

View File

@ -1,6 +1,6 @@
import classNames from 'classnames';
import Link from './Link';
import Button from './Button';
import { Button } from 'react-basics';
import XMark from 'assets/xmark.svg';
import styles from './MobileMenu.module.css';

View File

@ -1,26 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import styles from './Modal.module.css';
function Modal({ title, children }) {
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
return ReactDOM.createPortal(
<animated.div className={styles.modal} style={props}>
<div className={styles.content}>
{title && <div className={styles.header}>{title}</div>}
<div className={styles.body}>{children}</div>
</div>
</animated.div>,
document.getElementById('__modals'),
);
}
Modal.propTypes = {
title: PropTypes.node,
children: PropTypes.node,
};
export default Modal;

View File

@ -1,46 +0,0 @@
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
z-index: 2;
}
.modal:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
background: #000;
opacity: 0.5;
}
.content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--base50);
min-width: 400px;
min-height: 100px;
max-width: 100vw;
z-index: 1;
border: 1px solid var(--base300);
padding: 30px;
border-radius: 4px;
}
.header {
font-weight: 600;
margin-bottom: 20px;
}
.body {
display: flex;
flex-direction: column;
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useRouter } from 'next/router';
import classNames from 'classnames';

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import ReactTooltip from 'react-tooltip';
import styles from './OverflowText.module.css';

View File

@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import useStore from 'store/queries';
import { setDateRange } from 'store/websites';
import Button from './Button';
import { Button, Icon } from 'react-basics';
import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg';
import useDateRange from 'hooks/useDateRange';
@ -31,12 +31,13 @@ function RefreshButton({ websiteId }) {
return (
<Button
icon={loading ? <Dots /> : <Refresh />}
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
tooltipId="button-refresh"
size="small"
onClick={handleClick}
/>
>
<Icon>{loading ? <Dots /> : <Refresh />}</Icon>
</Button>
);
}

View File

@ -1,90 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import styles from './Table.module.css';
function Table({
columns,
rows,
empty,
className,
bodyClassName,
rowKey,
showHeader = true,
children,
}) {
if (empty && rows.length === 0) {
return empty;
}
return (
<div className={classNames(styles.table, className)}>
{showHeader && (
<div className={classNames(styles.header, 'row')}>
{columns.map(({ key, label, className, style, header }) => (
<div
key={key}
className={classNames(styles.head, className, header?.className)}
style={{ ...style, ...header?.style }}
>
{label}
</div>
))}
</div>
)}
<div className={classNames(styles.body, bodyClassName)}>
{rows.length === 0 && <NoData />}
{!children &&
rows.map((row, index) => {
const id = rowKey ? rowKey(row) : index;
return <TableRow key={id} columns={columns} row={row} />;
})}
{children}
</div>
</div>
);
}
const styledObject = PropTypes.shape({
className: PropTypes.string,
style: PropTypes.object,
});
Table.propTypes = {
columns: PropTypes.arrayOf(
PropTypes.shape({
cell: styledObject,
className: PropTypes.string,
header: styledObject,
key: PropTypes.string,
label: PropTypes.node,
render: PropTypes.func,
style: PropTypes.object,
}),
),
rows: PropTypes.arrayOf(PropTypes.object),
empty: PropTypes.node,
className: PropTypes.string,
bodyClassName: PropTypes.string,
rowKey: PropTypes.func,
showHeader: PropTypes.bool,
children: PropTypes.node,
};
export default Table;
export const TableRow = ({ columns, row }) => (
<div className={classNames(styles.row, 'row')}>
{columns.map(({ key, label, render, className, style, cell }, index) => (
<div
key={`${key}-${index}`}
className={classNames(styles.cell, className, cell?.className)}
style={{ ...style, ...cell?.style }}
>
{label && <label>{label}</label>}
{render ? render(row) : row[key]}
</div>
))}
</div>
);

View File

@ -1,55 +0,0 @@
.table {
display: flex;
flex-direction: column;
}
.table label {
display: none;
font-size: var(--font-size-xs);
font-weight: bold;
}
.header {
border-bottom: 1px solid var(--base300);
}
.head {
font-size: var(--font-size-sm);
font-weight: 600;
line-height: 40px;
}
.body {
position: relative;
display: flex;
flex-direction: column;
}
.row {
border-bottom: 1px solid var(--base300);
padding: 10px 0;
}
.cell {
display: flex;
flex-direction: column;
align-items: flex-start;
}
@media only screen and (max-width: 992px) {
.table label {
display: block;
}
.header {
display: none;
}
.row {
flex-direction: column;
}
.cell {
margin-bottom: 20px;
}
}

View File

@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Tag.module.css';

View File

@ -1,35 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import Icon from 'components/common/Icon';
import Close from 'assets/times.svg';
import styles from './Toast.module.css';
function Toast({ message, timeout = 3000, onClose }) {
const props = useSpring({
opacity: 1,
transform: 'translate3d(0,0px,0)',
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
});
useEffect(() => {
setTimeout(onClose, timeout);
}, []);
return ReactDOM.createPortal(
<animated.div className={styles.toast} style={props} onClick={onClose}>
<div className={styles.message}>{message}</div>
<Icon className={styles.close} icon={<Close />} size="small" />
</animated.div>,
document.getElementById('__modals'),
);
}
Toast.propTypes = {
message: PropTypes.node,
timeout: PropTypes.number,
onClose: PropTypes.func,
};
export default Toast;

View File

@ -1,25 +0,0 @@
.toast {
position: fixed;
top: 30px;
left: 0;
right: 0;
width: 300px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
color: var(--msgColor);
background: var(--green400);
margin: auto;
z-index: 2;
cursor: pointer;
}
.message {
font-size: var(--font-size-md);
}
.close {
margin-left: 20px;
}

View File

@ -4,7 +4,7 @@ import { setItem } from 'next-basics';
import ButtonLayout from 'components/layout/ButtonLayout';
import useStore, { checkVersion } from 'store/version';
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import Button from './Button';
import { Button } from 'react-basics';
import styles from './UpdateNotice.module.css';
export default function UpdateNotice() {

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';

View File

@ -1,107 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import useApi from 'hooks/useApi';
import useUser from 'hooks/useUser';
const initialValues = {
current_password: '',
new_password: '',
confirm_password: '',
};
const validate = ({ current_password, new_password, confirm_password }) => {
const errors = {};
if (!current_password) {
errors.current_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!new_password) {
errors.new_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!confirm_password) {
errors.confirm_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
} else if (new_password !== confirm_password) {
errors.confirm_password = (
<FormattedMessage id="label.passwords-dont-match" defaultMessage="Passwords don't match" />
);
}
return errors;
};
export default function ChangePasswordForm({ values, onSave, onClose }) {
const { post } = useApi();
const [message, setMessage] = useState();
const { user } = useUser();
const handleSubmit = async values => {
const { ok, error } = await post(`/users/${user.id}/password`, values);
if (ok) {
onSave();
} else {
setMessage(
error || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<FormRow>
<label htmlFor="current_password">
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
</label>
<div>
<Field name="current_password" type="password" />
<FormError name="current_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="new_password">
<FormattedMessage id="label.new-password" defaultMessage="New password" />
</label>
<div>
<Field name="new_password" type="password" />
<FormError name="new_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="confirm_password">
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
</label>
<div>
<Field name="confirm_password" type="password" />
<FormError name="confirm_password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View File

@ -1,12 +1,11 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import Calendar from 'components/common/Calendar';
import Button from 'components/common/Button';
import { FormButtons } from 'components/layout/FormLayout';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import { getDateRangeValues } from 'lib/date';
import { useState } from 'react';
import { Button, ButtonGroup } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import styles from './DatePickerForm.module.css';
import ButtonGroup from 'components/common/ButtonGroup';
const FILTER_DAY = 0;
const FILTER_RANGE = 1;

View File

@ -1,105 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import Loading from 'components/common/Loading';
import useApi from 'hooks/useApi';
const CONFIRMATION_WORD = 'DELETE';
const validate = ({ confirmation }) => {
const errors = {};
if (confirmation !== CONFIRMATION_WORD) {
errors.confirmation = !confirmation ? (
<FormattedMessage id="label.required" defaultMessage="Required" />
) : (
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
);
}
return errors;
};
export default function DeleteForm({ values, onSave, onClose }) {
const { del } = useApi();
const [message, setMessage] = useState();
const [deleting, setDeleting] = useState(false);
const handleSubmit = async ({ type, id }) => {
setDeleting(true);
const { ok, data } = await del(`/${type}/${id}`);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
setDeleting(false);
}
};
return (
<FormLayout>
{deleting && <Loading overlay />}
<Formik
initialValues={{ confirmation: '', ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{props => (
<Form>
<div>
<FormattedMessage
id="message.confirm-delete"
defaultMessage="Are your sure you want to delete {target}?"
values={{ target: <b>{values.name}</b> }}
/>
</div>
<div>
<FormattedMessage
id="message.delete-warning"
defaultMessage="All associated data will be deleted as well."
/>
</div>
<p>
<FormattedMessage
id="message.type-delete"
defaultMessage="Type {delete} in the box below to confirm."
values={{ delete: <b>{CONFIRMATION_WORD}</b> }}
/>
</p>
<FormRow>
<div>
<Field name="confirmation" type="text" />
<FormError name="confirmation" />
</div>
</FormRow>
<FormButtons>
<Button
type="submit"
variant="danger"
disabled={props.values.confirmation !== CONFIRMATION_WORD}
>
<FormattedMessage id="label.delete" defaultMessage="Delete" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View File

@ -1,5 +1,5 @@
import classNames from 'classnames';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import DateFilter from 'components/common/DateFilter';
import DropDown from 'components/common/DropDown';
import FormLayout, {
@ -109,8 +109,8 @@ export default function EventDataForm({ websiteId, onClose, className }) {
const handleSubmit = async () => {
const params = {
website_id: websiteId,
start_at: +startDate,
end_at: +endDate,
startAt: +startDate,
endAt: +endDate,
timezone,
columns,
filters,

View File

@ -9,9 +9,9 @@ import {
Icon,
} from 'react-basics';
import { useRouter } from 'next/router';
import { useApi } from 'next-basics';
import useApi from 'hooks/useApi';
import { setUser } from 'store/app';
import { setAuthToken } from 'lib/client';
import { setClientAuthToken } from 'lib/client';
import Logo from 'assets/logo.svg';
import styles from './Form.module.css';
@ -23,7 +23,7 @@ export default function LoginForm() {
const handleSubmit = async data => {
mutate(data, {
onSuccess: async ({ token, user }) => {
setAuthToken(token);
setClientAuthToken(token);
setUser(user);
await router.push('/websites');

View File

@ -1,98 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import useApi from 'hooks/useApi';
const CONFIRMATION_WORD = 'RESET';
const validate = ({ confirmation }) => {
const errors = {};
if (confirmation !== CONFIRMATION_WORD) {
errors.confirmation = !confirmation ? (
<FormattedMessage id="label.required" defaultMessage="Required" />
) : (
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
);
}
return errors;
};
export default function ResetForm({ values, onSave, onClose }) {
const { post } = useApi();
const [message, setMessage] = useState();
const handleSubmit = async ({ type, id }) => {
const { ok, data } = await post(`/${type}/${id}/reset`);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
return (
<FormLayout>
<Formik
initialValues={{ confirmation: '', ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{props => (
<Form>
<div>
<FormattedMessage
id="message.confirm-reset"
defaultMessage="Are your sure you want to reset {target}'s statistics?"
values={{ target: <b>{values.name}</b> }}
/>
</div>
<div>
<FormattedMessage
id="message.reset-warning"
defaultMessage="All statistics for this website will be deleted, but your tracking code will remain intact."
/>
</div>
<p>
<FormattedMessage
id="message.type-reset"
defaultMessage="Type {reset} in the box below to confirm."
values={{ reset: <b>{CONFIRMATION_WORD}</b> }}
/>
</p>
<FormRow>
<div>
<Field name="confirmation" type="text" />
<FormError name="confirmation" />
</div>
</FormRow>
<FormButtons>
<Button
type="submit"
variant="danger"
disabled={props.values.confirmation !== CONFIRMATION_WORD}
>
<FormattedMessage id="label.reset" defaultMessage="Reset" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View File

@ -1,42 +1,85 @@
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import Button from 'components/common/Button';
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
import CopyButton from 'components/common/CopyButton';
import { useMutation } from '@tanstack/react-query';
import { getRandomChars } from 'next-basics';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Form,
FormButtons,
FormRow,
HiddenInput,
SubmitButton,
TextField,
Toggle,
} from 'react-basics';
import useApi from 'hooks/useApi';
export default function TrackingCodeForm({ values, onClose }) {
const ref = useRef();
const { basePath } = useRouter();
const { name, shareId } = values;
export default function ShareUrlForm({ websiteId, data, onSave }) {
const { name, shareId } = data;
const [id, setId] = useState(shareId);
const { post } = useApi();
const { mutate, error } = useMutation(({ shareId }) =>
post(`/websites/${websiteId}`, { shareId }),
);
const ref = useRef(null);
const url = useMemo(
() => `${process.env.analyticsUrl}/share/${id}/${encodeURIComponent(name)}`,
[id, name],
);
const generateId = () => getRandomChars(16);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave(data);
ref.current.reset(data);
},
});
};
const handleGenerate = () => {
const id = generateId();
ref.current.setValue('shareId', id, {
shouldValidate: true,
shouldDirty: true,
});
setId(id);
};
const handleChange = checked => {
const data = { shareId: checked ? generateId() : null };
mutate(data, {
onSuccess: async () => {
onSave(data);
},
});
setId(data.shareId);
};
useEffect(() => {
if (id && id !== shareId) {
ref.current.setValue('shareId', id);
}
}, [id, shareId]);
return (
<FormLayout>
<p>
<FormattedMessage
id="message.share-url"
defaultMessage="This is the publicly shared URL for {target}."
values={{ target: <b>{values.name}</b> }}
/>
</p>
<FormRow>
<textarea
ref={ref}
rows={3}
cols={60}
spellCheck={false}
defaultValue={`${
document.location.origin
}${basePath}/share/${shareId}/${encodeURIComponent(name)}`}
readOnly
/>
</FormRow>
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>
<>
<Toggle checked={Boolean(id)} onChange={handleChange}>
Enable share URL
</Toggle>
{id && (
<Form key={websiteId} ref={ref} onSubmit={handleSubmit} error={error} values={data}>
<FormRow>
<p>Your website stats are publically available at the following URL:</p>
<TextField value={url} readOnly allowCopy />
</FormRow>
<HiddenInput name="shareId" />
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
<Button onClick={handleGenerate}>Regenerate URL</Button>
</FormButtons>
</Form>
)}
</>
);
}

View File

@ -0,0 +1,35 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, TextField, Button } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './Form.module.css';
import { useMutation } from '@tanstack/react-query';
export default function TeamAddForm({ onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data => post('/teams', data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isLoading}>
Save
</Button>
<Button disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -0,0 +1,33 @@
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
import { useMutation } from '@tanstack/react-query';
import { useRef } from 'react';
import useApi from 'hooks/useApi';
export default function TeamEditForm({ teamId, data, onSave }) {
const { post } = useApi();
const { mutate, error } = useMutation(data => post(`/teams/${teamId}`, data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
ref.current.reset(data);
onSave(data);
},
});
};
return (
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
<FormRow label="Team ID">
<TextField value={teamId} readOnly allowCopy />
</FormRow>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField />
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -1,43 +1,23 @@
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import Button from 'components/common/Button';
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
import CopyButton from 'components/common/CopyButton';
import useConfig from 'hooks/useConfig';
import { useRef } from 'react';
import { Form, FormRow, TextArea } from 'react-basics';
export default function TrackingCodeForm({ values, onClose }) {
const ref = useRef();
const { basePath } = useRouter();
export default function TrackingCodeForm({ websiteId }) {
const ref = useRef(null);
const { trackerScriptName } = useConfig();
const code = `<script async defer src="${trackerScriptName}" data-website-id="${websiteId}"></script>`;
return (
<FormLayout>
<p>
<FormattedMessage
id="message.track-stats"
defaultMessage="To track stats for {target}, place the following code in the {head} section of your website."
values={{ head: '<head>', target: <b>{values.name}</b> }}
/>
</p>
<FormRow>
<textarea
ref={ref}
rows={3}
cols={60}
spellCheck={false}
defaultValue={`<script async defer data-website-id="${values.id}" src="${
document.location.origin
}${basePath}/${trackerScriptName ? `${trackerScriptName}.js` : 'umami.js'}"></script>`}
readOnly
/>
</FormRow>
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>
<>
<Form ref={ref}>
<FormRow>
<p>
To track stats for this website, place the following code in the{' '}
<code>&lt;head&gt;</code> section of your HTML.
</p>
<TextArea rows={4} value={code} readOnly allowCopy />
</FormRow>
</Form>
</>
);
}

View File

@ -0,0 +1,42 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'DELETE';
export default function UserDeleteForm({ userId, onSave, onClose }) {
const { del } = useApi();
const { mutate, error, isLoading } = useMutation(data => del(`/users/${userId}`, data));
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To delete this user, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirmation"
label="Confirm"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,89 +1,64 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
import {
Dropdown,
Item,
Form,
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
FormInput,
TextField,
SubmitButton,
} from 'react-basics';
import { useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { ROLES } from 'lib/constants';
import styles from './UserForm.module.css';
const initialValues = {
username: '',
password: '',
};
const items = [
{
value: ROLES.user,
label: 'User',
},
{
value: ROLES.admin,
label: 'Admin',
},
];
const validate = ({ id, username, password }) => {
const errors = {};
if (!username) {
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!id && !password) {
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
return errors;
};
export default function UserEditForm({ values, onSave, onClose }) {
export default function UserEditForm({ data, onSave }) {
const { id } = data;
const { post } = useApi();
const [message, setMessage] = useState();
const { mutate, error } = useMutation(({ username }) => post(`/user/${id}`, { username }));
const ref = useRef(null);
const handleSubmit = async values => {
const { id } = values;
const { ok, data } = await post(id ? `/users/${id}` : '/users', values);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave(data);
ref.current.reset(data);
},
});
};
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<FormRow>
<label htmlFor="username">
<FormattedMessage id="label.username" defaultMessage="Username" />
</label>
<div>
<Field name="username" type="text" />
<FormError name="username" />
</div>
</FormRow>
<FormRow>
<label htmlFor="password">
<FormattedMessage id="label.password" defaultMessage="Password" />
</label>
<div>
<Field name="password" type="password" />
<FormError name="password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
<Form
key={id}
className={styles.form}
ref={ref}
onSubmit={handleSubmit}
error={error}
values={data}
>
<FormInput name="username" label="Username">
<TextField />
</FormInput>
<FormInput name="role" label="Role">
<Dropdown items={items} style={{ width: 200 }}>
{({ value, label }) => <Item key={value}>{label}</Item>}
</Dropdown>
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -0,0 +1,6 @@
.form {
display: flex;
flex-direction: column;
gap: 30px;
width: 300px;
}

View File

@ -0,0 +1,74 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './UserPasswordForm.module.css';
import useUser from 'hooks/useUser';
export default function UserPasswordForm({ onSave, onClose, userId }) {
const user = useUser();
const isCurrentUser = !userId || user?.id === userId;
const url = isCurrentUser ? `/users/${user?.id}/password` : `/users/${user?.id}`;
const { post, useMutation } = useApi();
const { mutate, error, isLoading } = useMutation(data => post(url, data));
const ref = useRef(null);
const handleSubmit = async data => {
const payload = isCurrentUser
? data
: {
password: data.newPassword,
};
mutate(payload, {
onSuccess: async () => {
onSave();
ref.current.reset();
},
});
};
const samePassword = value => {
if (value !== ref?.current?.getValues('newPassword')) {
return "Passwords don't match";
}
return true;
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
{isCurrentUser && (
<FormInput name="currentPassword" label="Current password" rules={{ required: 'Required' }}>
<PasswordField autoComplete="off" />
</FormInput>
)}
<FormInput
name="newPassword"
label="New password"
rules={{
required: 'Required',
minLength: { value: 8, message: 'Minimum length 8 characters' },
}}
>
<PasswordField autoComplete="off" />
</FormInput>
<FormInput
name="confirmPassword"
label="Confirm password"
rules={{
required: 'Required',
minLength: { value: 8, message: 'Minimum length 8 characters' },
validate: samePassword,
}}
>
<PasswordField autoComplete="off" />
</FormInput>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isLoading}>
Save
</Button>
<Button onClick={onClose}>Close</Button>
</FormButtons>
</Form>
);
}

View File

@ -0,0 +1,6 @@
.form {
display: flex;
flex-direction: column;
gap: 30px;
width: 300px;
}

View File

@ -0,0 +1,46 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, TextField, Button, SubmitButton } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './Form.module.css';
import { useMutation } from '@tanstack/react-query';
import { DOMAIN_REGEX } from 'lib/constants';
export default function WebsiteAddForm({ onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data => post('/websites', data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField autoComplete="off" />
</FormInput>
<FormInput
name="domain"
label="Domain"
rules={{
required: 'Required',
pattern: { value: DOMAIN_REGEX, message: 'Invalid domain' },
}}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" disabled={false}>
Save
</SubmitButton>
<Button disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -0,0 +1,42 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'DELETE';
export default function WebsiteDeleteForm({ websiteId, onSave, onClose }) {
const { del } = useApi();
const { mutate, error, isLoading } = useMutation(data => del(`/websites/${websiteId}`, data));
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To delete this website, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirmation"
label="Confirm"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,159 +1,47 @@
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field, useFormikContext } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import Checkbox from 'components/common/Checkbox';
import { DOMAIN_REGEX } from 'lib/constants';
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
import { useMutation } from '@tanstack/react-query';
import { useRef } from 'react';
import useApi from 'hooks/useApi';
import useFetch from 'hooks/useFetch';
import useUser from 'hooks/useUser';
import styles from './WebsiteEditForm.module.css';
import { DOMAIN_REGEX } from 'lib/constants';
const initialValues = {
name: '',
domain: '',
owner: '',
public: false,
};
const validate = ({ name, domain }) => {
const errors = {};
if (!name) {
errors.name = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!domain) {
errors.domain = <FormattedMessage id="label.required" defaultMessage="Required" />;
} else if (!DOMAIN_REGEX.test(domain)) {
errors.domain = <FormattedMessage id="label.invalid-domain" defaultMessage="Invalid domain" />;
}
return errors;
};
const OwnerDropDown = ({ user, users }) => {
const { setFieldValue, values } = useFormikContext();
useEffect(() => {
if (values.userId != null && values.owner === '') {
setFieldValue('owner', values.userId.toString());
} else if (user?.id && values.owner === '') {
setFieldValue('owner', user.id.toString());
}
}, [users, setFieldValue, user, values]);
if (user?.isAdmin) {
return (
<FormRow>
<label htmlFor="owner">
<FormattedMessage id="label.owner" defaultMessage="Owner" />
</label>
<div>
<Field as="select" name="owner" className={styles.dropdown}>
{users?.map(acc => (
<option key={acc.id} value={acc.id}>
{acc.username}
</option>
))}
</Field>
<FormError name="owner" />
</div>
</FormRow>
);
} else {
return null;
}
};
export default function WebsiteEditForm({ values, onSave, onClose }) {
export default function WebsiteEditForm({ websiteId, data, onSave }) {
const { post } = useApi();
const { data: users } = useFetch(`/users`);
const { user } = useUser();
const [message, setMessage] = useState();
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}`, data));
const ref = useRef(null);
const handleSubmit = async values => {
const { id } = values;
const { ok, data } = await post(id ? `/websites/${id}` : '/websites', values);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
ref.current.reset(data);
onSave(data);
},
});
};
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values, enableShareUrl: !!values?.shareId }}
validate={validate}
onSubmit={handleSubmit}
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
<FormRow label="Website ID">
<TextField value={websiteId} readOnly allowCopy />
</FormRow>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField />
</FormInput>
<FormInput
name="domain"
label="Domain"
rules={{
required: 'Required',
pattern: {
value: DOMAIN_REGEX,
message: 'Invalid domain',
},
}}
>
{() => (
<Form>
<FormRow>
<label htmlFor="name">
<FormattedMessage id="label.name" defaultMessage="Name" />
</label>
<div>
<Field name="name" type="text" />
<FormError name="name" />
</div>
</FormRow>
<FormRow>
<label htmlFor="domain">
<FormattedMessage id="label.domain" defaultMessage="Domain" />
</label>
<div>
<Field
name="domain"
type="text"
placeholder="example.com"
spellCheck="false"
autoCapitalize="off"
autoCorrect="off"
/>
<FormError name="domain" />
</div>
</FormRow>
<OwnerDropDown users={users} user={user} />
<FormRow>
<label />
<Field name="enableShareUrl">
{({ field }) => (
<Checkbox
{...field}
label={
<FormattedMessage
id="label.enable-share-url"
defaultMessage="Enable share URL"
/>
}
/>
)}
</Field>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
<TextField />
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -1,5 +0,0 @@
.dropdown {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}

View File

@ -0,0 +1,49 @@
import WebsiteDeleteForm from 'components/forms/WebsiteDeleteForm';
import WebsiteResetForm from 'components/forms/WebsiteResetForm';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { Button, Form, FormRow, Modal } from 'react-basics';
export default function WebsiteReset({ websiteId, onSave }) {
const [modal, setModal] = useState(null);
const router = useRouter();
const handleReset = async () => {
setModal(null);
onSave();
};
const handleDelete = async () => {
onSave();
await router.push('/websites');
};
const handleClose = () => setModal(null);
return (
<Form>
<FormRow label="Reset website">
<p>
All statistics for this website will be deleted, but your settings will remain intact.
</p>
<Button onClick={() => setModal('reset')}>Reset</Button>
</FormRow>
<FormRow label="Delete website">
<p>All website data will be deleted.</p>
<Button onClick={() => setModal('delete')}>Delete</Button>
</FormRow>
{modal === 'reset' && (
<Modal title="Reset website" onClose={handleClose}>
{close => <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />}
</Modal>
)}
{modal === 'delete' && (
<Modal title="Delete website" onClose={handleClose}>
{close => (
<WebsiteDeleteForm websiteId={websiteId} onSave={handleDelete} onClose={close} />
)}
</Modal>
)}
</Form>
);
}

View File

@ -0,0 +1,44 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'RESET';
export default function WebsiteResetForm({ websiteId, onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data =>
post(`/websites/${websiteId}/reset`, data),
);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To reset this website, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirm"
label="Confirmation"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect } from 'react';
function isInViewport(element) {
const rect = element.getBoundingClientRect();

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect } from 'react';
import classNames from 'classnames';
export default function StickyHeader({

View File

@ -1,4 +1,3 @@
import React from 'react';
import classNames from 'classnames';
import styles from './ButtonLayout.module.css';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import { ErrorMessage } from 'formik';

View File

@ -1,4 +1,3 @@
import React from 'react';
import classNames from 'classnames';
import styles from './GridLayout.module.css';

View File

@ -1,22 +1,21 @@
import { Row, Column } from 'react-basics';
import { useRouter } from 'next/router';
import { FormattedMessage } from 'react-intl';
import Logo from 'assets/logo.svg';
import HamburgerButton from 'components/common/HamburgerButton';
import Link from 'components/common/Link';
import Icon from 'components/common/Icon';
import UpdateNotice from 'components/common/UpdateNotice';
import LanguageButton from 'components/settings/LanguageButton';
import ThemeButton from 'components/settings/ThemeButton';
import HamburgerButton from 'components/common/HamburgerButton';
import UpdateNotice from 'components/common/UpdateNotice';
import UserButton from 'components/settings/UserButton';
import { HOMEPAGE_URL } from 'lib/constants';
import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser';
import Logo from 'assets/logo.svg';
import styles from './Header.module.css';
import { HOMEPAGE_URL } from 'lib/constants';
import { useRouter } from 'next/router';
import { Column, Icon, Row } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import SettingsButton from '../settings/SettingsButton';
import styles from './Header.module.css';
export default function Header() {
const { user } = useUser();
const user = useUser();
const { pathname } = useRouter();
const { updatesDisabled, adminDisabled } = useConfig();
const isSharePage = pathname.includes('/share/');
@ -28,7 +27,9 @@ export default function Header() {
<header className={styles.header}>
<Row>
<Column className={styles.title}>
<Icon icon={<Logo />} size="large" className={styles.logo} />
<Icon size="lg" className={styles.logo}>
<Logo />
</Icon>
<Link href={isSharePage ? HOMEPAGE_URL : '/'}>umami</Link>
</Column>
<HamburgerButton />
@ -40,7 +41,7 @@ export default function Header() {
<Link href="/realtime">
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</Link>
<Link href="/settings">
<Link href="/websites">
<FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link>
</div>

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import NavMenu from 'components/common/NavMenu';

View File

@ -1,6 +1,15 @@
import classNames from 'classnames';
import styles from './Page.module.css';
import { Banner, Loading } from 'react-basics';
export default function Page({ className, error, loading, children }) {
if (error) {
return <Banner variant="error">Something went wrong.</Banner>;
}
if (loading) {
return <Loading />;
}
export default function Page({ className, children }) {
return <div className={classNames(styles.page, className)}>{children}</div>;
}

View File

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

View File

@ -1,7 +1,24 @@
import React from 'react';
import Link from 'next/link';
import classNames from 'classnames';
import { Button, Icon } from 'react-basics';
import styles from './PageHeader.module.css';
export default function PageHeader({ children, className }) {
return <div className={classNames(styles.header, className)}>{children}</div>;
export default function PageHeader({ title, backUrl, children, className, style }) {
return (
<div className={classNames(styles.header, className)} style={style}>
<div className={styles.title}>
{backUrl && (
<Link href={backUrl}>
<a>
<Button>
<Icon icon="arrow-left" /> Back
</Button>
</a>
</Link>
)}
{title}
</div>
{children}
</div>
);
}

View File

@ -3,7 +3,23 @@
justify-content: space-between;
align-items: center;
align-content: center;
min-height: 80px;
align-self: stretch;
margin-bottom: 40px;
font-size: 18px;
font-weight: bold;
height: 50px;
}
.header a {
color: var(--base600);
}
.header a:hover {
color: var(--base900);
}
.title {
display: flex;
align-items: center;
gap: 20px;
}

View File

@ -1,15 +1,17 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { StatusLight } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import useFetch from 'hooks/useFetch';
import Dot from 'components/common/Dot';
import useApi from 'hooks/useApi';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className, value, interval = 60000 }) {
export default function ActiveUsers({ websiteId, className, value, refetchInterval = 60000 }) {
const url = websiteId ? `/websites/${websiteId}/active` : null;
const { data } = useFetch(url, {
interval,
const { get, useQuery } = useApi();
const { data } = useQuery(['websites:active', websiteId], () => get(url), {
refetchInterval,
});
const count = useMemo(() => {
if (websiteId) {
return data?.[0]?.x || 0;
@ -24,7 +26,7 @@ export default function ActiveUsers({ websiteId, className, value, interval = 60
return (
<div className={classNames(styles.container, className)}>
<Dot />
<StatusLight variant="success" />
<div className={styles.text}>
<div>
<FormattedMessage

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect } from 'react';
import classNames from 'classnames';
import ChartJS from 'chart.js';
import Legend from 'components/metrics/Legend';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import FilterLink from 'components/common/FilterLink';
import MetricsTable from 'components/metrics/MetricsTable';

View File

@ -1,5 +1,4 @@
import React from 'react';
import Dot from 'components/common/Dot';
import { StatusLight } from 'react-basics';
import styles from './ChartTooltip.module.css';
import ReactTooltip from 'react-tooltip';
@ -16,7 +15,7 @@ export default function ChartTooltip({ chartId, tooltip }) {
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<Dot color={labelColor} />
<StatusLight color={labelColor} />
{value} {label}
</div>
</div>

View File

@ -1,4 +1,3 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { percentFilter } from 'lib/filters';
import { useIntl, defineMessages } from 'react-intl';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';

View File

@ -1,4 +1,3 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { useIntl, FormattedMessage } from 'react-intl';
import { getDeviceMessage } from 'components/messages';

View File

@ -1,39 +1,37 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import { getDateArray, getDateLength } from 'lib/date';
import useFetch from 'hooks/useFetch';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { EVENT_COLORS } from 'lib/constants';
export default function EventsChart({ websiteId, className, token }) {
const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
const [timezone] = useTimezone();
const {
query: { url, eventName },
} = usePageQuery();
const { data, loading } = useFetch(
`/websites/${websiteId}/events`,
{
params: {
start_at: +startDate,
end_at: +endDate,
unit,
tz: timezone,
url,
event_name: eventName,
token,
},
},
[modified, eventName],
const { data, isLoading } = useQuery(['events', { websiteId, modified, eventName }], () =>
get(`/websites/${websiteId}/events`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
eventName,
token,
}),
);
const datasets = useMemo(() => {
if (!data) return [];
if (loading) return data;
if (isLoading) return data;
const map = data.reduce((obj, { x, t, y }) => {
if (!obj[x]) {
@ -60,7 +58,7 @@ export default function EventsChart({ websiteId, className, token }) {
borderWidth: 1,
};
});
}, [data, loading]);
}, [data, isLoading]);
function handleUpdate(chart) {
chart.data.datasets = datasets;
@ -68,6 +66,10 @@ export default function EventsChart({ websiteId, className, token }) {
chart.update();
}
if (isLoading) {
return <Loading variant="dots" />;
}
if (!data) {
return null;
}
@ -81,7 +83,7 @@ export default function EventsChart({ websiteId, className, token }) {
height={300}
records={getDateLength(startDate, endDate, unit)}
onUpdate={handleUpdate}
loading={loading}
loading={isLoading}
stacked
/>
);

View File

@ -1,7 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import Times from 'assets/times.svg';
import styles from './FilterTags.module.css';

View File

@ -1,4 +1,3 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { percentFilter } from 'lib/filters';
import { FormattedMessage } from 'react-intl';

View File

@ -1,7 +1,6 @@
import React from 'react';
import { StatusLight } from 'react-basics';
import { colord } from 'colord';
import classNames from 'classnames';
import Dot from 'components/common/Dot';
import useLocale from 'hooks/useLocale';
import useForceUpdate from 'hooks/useForceUpdate';
import styles from './Legend.module.css';
@ -35,7 +34,7 @@ export default function Legend({ chart }) {
className={classNames(styles.label, { [styles.hidden]: hidden })}
onClick={() => handleClick(datasetIndex)}
>
<Dot color={color.alpha(color.alpha() + 0.2).toHex()} />
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()} />
<span className={locale}>{text}</span>
</div>
);

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useSpring, animated } from 'react-spring';
import { formatNumber } from 'lib/format';
import styles from './MetricCard.module.css';

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { Loading } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Loading from 'components/common/Loading';
import ErrorMessage from 'components/common/ErrorMessage';
import useFetch from 'hooks/useFetch';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
@ -11,6 +11,7 @@ import MetricCard from './MetricCard';
import styles from './MetricsBar.module.css';
export default function MetricsBar({ websiteId, className }) {
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
@ -18,21 +19,19 @@ export default function MetricsBar({ websiteId, className }) {
query: { url, referrer, os, browser, device, country },
} = usePageQuery();
const { data, error, loading } = useFetch(
`/websites/${websiteId}/stats`,
{
params: {
start_at: +startDate,
end_at: +endDate,
const { data, error, isLoading } = useQuery(
['websites:stats', { websiteId, modified, url, referrer, os, browser, device, country }],
() =>
get(`/websites/${websiteId}/stats`, {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
os,
browser,
device,
country,
},
},
[modified, url, referrer, os, browser, device, country],
}),
);
const formatFunc = format
@ -54,7 +53,7 @@ export default function MetricsBar({ websiteId, className }) {
return (
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
{!data && loading && <Loading />}
{isLoading && <Loading variant="dots" />}
{error && <ErrorMessage />}
{data && !error && (
<>

View File

@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { defineMessages, useIntl } from 'react-intl';
import firstBy from 'thenby';
import classNames from 'classnames';
import Link from 'components/common/Link';
import Loading from 'components/common/Loading';
import useFetch from 'hooks/useFetch';
import useApi from 'hooks/useApi';
import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters';
import useDateRange from 'hooks/useDateRange';
@ -36,25 +36,26 @@ export default function MetricsTable({
query: { url, referrer, os, browser, device, country },
} = usePageQuery();
const { formatMessage } = useIntl();
const { get, useQuery } = useApi();
const { data, loading, error } = useFetch(
`/websites/${websiteId}/metrics`,
{
params: {
const { data, isLoading, error } = useQuery(
[
'websites:mnetrics',
{ websiteId, type, modified, url, referrer, os, browser, device, country },
],
() =>
get(`/websites/${websiteId}/metrics`, {
type,
start_at: +startDate,
end_at: +endDate,
startAt: +startDate,
endAt: +endDate,
url,
referrer,
os,
browser,
device,
country,
},
onDataLoad,
delay: delay || DEFAULT_ANIMATION_DURATION,
},
[type, modified, url, referrer, os, browser, device, country],
}),
{ onSuccess: onDataLoad, retryDelay: delay || DEFAULT_ANIMATION_DURATION },
);
const filteredData = useMemo(() => {
@ -73,7 +74,7 @@ export default function MetricsTable({
return (
<div className={classNames(styles.container, className)}>
{!data && loading && <Loading />}
{!data && isLoading && <Loading variant="dots" />}
{error && <ErrorMessage />}
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
<div className={styles.footer}>

View File

@ -1,4 +1,3 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { FormattedMessage } from 'react-intl';
import FilterLink from 'components/common/FilterLink';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useIntl, defineMessage } from 'react-intl';
import FilterLink from 'components/common/FilterLink';
import FilterButtons from 'components/common/FilterButtons';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { colord } from 'colord';
import CheckVisible from 'components/helpers/CheckVisible';

View File

@ -1,4 +1,4 @@
import React, { useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { format, parseISO, startOfMinute, subMinutes, isBefore } from 'date-fns';
import PageviewsChart from './PageviewsChart';
import { getDateArray } from 'lib/date';

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { differenceInMinutes } from 'date-fns';
import PageHeader from 'components/layout/PageHeader';

View File

@ -1,9 +1,9 @@
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { StatusLight } from 'react-basics';
import { FormattedMessage, useIntl } from 'react-intl';
import { FixedSizeList } from 'react-window';
import firstBy from 'thenby';
import Icon from 'components/common/Icon';
import Dot from 'components/common/Dot';
import { Icon } from 'react-basics';
import FilterButtons from 'components/common/FilterButtons';
import NoData from 'components/common/NoData';
import { getDeviceMessage, labels } from 'components/messages';
@ -149,7 +149,7 @@ export default function RealtimeLog({ data, websites, websiteId }) {
return (
<div className={styles.row} style={style}>
<div>
<Dot color={getColor(row)} />
<StatusLight color={getColor(row)} />
</div>
<div className={styles.time}>{getTime(row)}</div>
<div className={styles.detail}>

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback } from 'react';
import { useMemo, useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import MetricsTable from './MetricsTable';
import FilterButtons from 'components/common/FilterButtons';

View File

@ -1,4 +1,3 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { FormattedMessage } from 'react-intl';

Some files were not shown because too many files have changed in this diff Show More