Rewrite admin. (#1713)

* Rewrite admin.

* Clean up password forms.

* Fix naming issues.

* CSS Naming.
This commit is contained in:
Brian Cao 2022-12-26 16:57:59 -08:00 committed by GitHub
parent f4db04c3c6
commit e1f99a7d01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
113 changed files with 2054 additions and 1872 deletions

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

@ -16,7 +16,7 @@ import {
isBefore, isBefore,
isAfter, isAfter,
} from 'date-fns'; } from 'date-fns';
import Button from './Button'; import { Button, Icon } from 'react-basics';
import useLocale from 'hooks/useLocale'; import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date'; import { dateFormat } from 'lib/date';
import { chunk } from 'lib/array'; import { chunk } from 'lib/array';
@ -24,7 +24,6 @@ import { getDateLocale } from 'lib/lang';
import Chevron from 'assets/chevron-down.svg'; import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg'; import Cross from 'assets/times.svg';
import styles from './Calendar.module.css'; import styles from './Calendar.module.css';
import Icon from './Icon';
export default function Calendar({ date, minDate, maxDate, onChange }) { export default function Calendar({ date, minDate, maxDate, onChange }) {
const { locale } = useLocale(); const { locale } = useLocale();
@ -61,14 +60,18 @@ export default function Calendar({ date, minDate, maxDate, onChange }) {
onClick={toggleMonthSelect} onClick={toggleMonthSelect}
> >
{month} {month}
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" /> <Icon className={styles.icon} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</div> </div>
<div <div
className={classNames(styles.selector, { [styles.open]: selectYear })} className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect} onClick={toggleYearSelect}
> >
{year} {year}
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" /> <Icon className={styles.icon} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</div> </div>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
@ -230,12 +233,15 @@ const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
<div className={styles.pager}> <div className={styles.pager}>
<div className={styles.left}> <div className={styles.left}>
<Button <Button
icon={<Chevron />}
size="small" size="small"
onClick={handlePrevClick} onClick={handlePrevClick}
disabled={years[0] <= minYear} disabled={years[0] <= minYear}
variant="light" variant="light"
/> >
<Icon>
<Chevron />
</Icon>
</Button>
</div> </div>
<div className={styles.middle}> <div className={styles.middle}>
<table> <table>
@ -261,12 +267,15 @@ const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
</div> </div>
<div className={styles.right}> <div className={styles.right}>
<Button <Button
icon={<Chevron />}
size="small" size="small"
onClick={handleNextClick} onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear} disabled={years[years.length - 1] > maxYear}
variant="light" variant="light"
/> >
<Icon>
<Chevron />
</Icon>
</Button>
</div> </div>
</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 React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Button from './Button'; import { Button } from 'react-basics';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const defaultText = ( const defaultText = (

View File

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

View File

@ -5,7 +5,7 @@ import Menu from './Menu';
import useDocumentClick from 'hooks/useDocumentClick'; import useDocumentClick from 'hooks/useDocumentClick';
import Chevron from 'assets/chevron-down.svg'; import Chevron from 'assets/chevron-down.svg';
import styles from './Dropdown.module.css'; import styles from './Dropdown.module.css';
import Icon from './Icon'; import { Icon } from 'react-basics';
function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) { function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) {
const [showMenu, setShowMenu] = useState(false); 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 ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}> <div className={styles.value}>
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div> <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> </div>
{showMenu && ( {showMenu && (
<Menu <Menu

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import Link from 'next/link';
import { safeDecodeURI } from 'next-basics'; import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'hooks/usePageQuery'; import usePageQuery from 'hooks/usePageQuery';
import External from 'assets/arrow-up-right-from-square.svg'; 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'; import styles from './FilterLink.module.css';
export default function FilterLink({ id, value, label, externalUrl }) { export default function FilterLink({ id, value, label, externalUrl }) {
@ -26,7 +26,9 @@ export default function FilterLink({ id, value, label, externalUrl }) {
</Link> </Link>
{externalUrl && ( {externalUrl && (
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener"> <a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon icon={<External />} className={styles.icon} /> <Icon className={styles.icon}>
<External />
</Icon>
</a> </a>
)} )}
</div> </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 XMark from 'assets/xmark.svg';
import Bars from 'assets/bars.svg'; import Bars from 'assets/bars.svg';
import { useState } from 'react'; import { useState } from 'react';
@ -33,11 +33,9 @@ export default function HamburgerButton() {
return ( return (
<> <>
<Button <Button className={styles.button} onClick={handleClick}>
className={styles.button} <Icon>{active ? <XMark /> : <Bars />}</Icon>
icon={active ? <XMark /> : <Bars />} </Button>
onClick={handleClick}
/>
{active && <MobileMenu items={menuItems} onClose={handleClose} />} {active && <MobileMenu items={menuItems} onClose={handleClose} />}
</> </>
); );

View File

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

View File

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

View File

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

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import useStore from 'store/queries'; import useStore from 'store/queries';
import { setDateRange } from 'store/websites'; import { setDateRange } from 'store/websites';
import Button from './Button'; import { Button, Icon } from 'react-basics';
import Refresh from 'assets/redo.svg'; import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg'; import Dots from 'assets/ellipsis-h.svg';
import useDateRange from 'hooks/useDateRange'; import useDateRange from 'hooks/useDateRange';
@ -31,12 +31,13 @@ function RefreshButton({ websiteId }) {
return ( return (
<Button <Button
icon={loading ? <Dots /> : <Refresh />}
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />} tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
tooltipId="button-refresh" tooltipId="button-refresh"
size="small" size="small"
onClick={handleClick} 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,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 ButtonLayout from 'components/layout/ButtonLayout';
import useStore, { checkVersion } from 'store/version'; import useStore, { checkVersion } from 'store/version';
import { REPO_URL, VERSION_CHECK } from 'lib/constants'; import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import Button from './Button'; import { Button } from 'react-basics';
import styles from './UpdateNotice.module.css'; import styles from './UpdateNotice.module.css';
export default function UpdateNotice() { export default function UpdateNotice() {

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 Calendar from 'components/common/Calendar';
import Button from 'components/common/Button';
import { FormButtons } from 'components/layout/FormLayout'; import { FormButtons } from 'components/layout/FormLayout';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import { getDateRangeValues } from 'lib/date'; import { getDateRangeValues } from 'lib/date';
import React, { useState } from 'react';
import { Button, ButtonGroup } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import styles from './DatePickerForm.module.css'; import styles from './DatePickerForm.module.css';
import ButtonGroup from 'components/common/ButtonGroup';
const FILTER_DAY = 0; const FILTER_DAY = 0;
const FILTER_RANGE = 1; 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 classNames from 'classnames';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import DateFilter from 'components/common/DateFilter'; import DateFilter from 'components/common/DateFilter';
import DropDown from 'components/common/DropDown'; import DropDown from 'components/common/DropDown';
import FormLayout, { import FormLayout, {

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 { useMutation } from '@tanstack/react-query';
import { FormattedMessage } from 'react-intl'; import { getAuthToken } from 'lib/client';
import { useRouter } from 'next/router'; import { getRandomChars, useApi } from 'next-basics';
import Button from 'components/common/Button'; import { useEffect, useMemo, useRef, useState } from 'react';
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout'; import {
import CopyButton from 'components/common/CopyButton'; Button,
Form,
FormButtons,
FormRow,
HiddenInput,
SubmitButton,
TextField,
Toggle,
} from 'react-basics';
export default function TrackingCodeForm({ values, onClose }) { export default function ShareUrlForm({ websiteId, data, onSave }) {
const ref = useRef(); const { name, shareId } = data;
const { basePath } = useRouter(); const [id, setId] = useState(shareId);
const { name, shareId } = values; const { post } = useApi(getAuthToken());
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 ( return (
<FormLayout> <>
<p> <Toggle checked={Boolean(id)} onChange={handleChange}>
<FormattedMessage Enable share URL
id="message.share-url" </Toggle>
defaultMessage="This is the publicly shared URL for {target}." {id && (
values={{ target: <b>{values.name}</b> }} <Form key={websiteId} ref={ref} onSubmit={handleSubmit} error={error} values={data}>
/>
</p>
<FormRow> <FormRow>
<textarea <p>Your website stats are publically available at the following URL:</p>
ref={ref} <TextField value={url} readOnly allowCopy />
rows={3}
cols={60}
spellCheck={false}
defaultValue={`${
document.location.origin
}${basePath}/share/${shareId}/${encodeURIComponent(name)}`}
readOnly
/>
</FormRow> </FormRow>
<HiddenInput name="shareId" />
<FormButtons> <FormButtons>
<CopyButton type="submit" variant="action" element={ref} /> <SubmitButton variant="primary">Save</SubmitButton>
<Button onClick={onClose}> <Button onClick={handleGenerate}>Regenerate URL</Button>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons> </FormButtons>
</FormLayout> </Form>
)}
</>
); );
} }

View File

@ -0,0 +1,36 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, TextField, Button } from 'react-basics';
import { useApi } from 'next-basics';
import styles from './Form.module.css';
import { useMutation } from '@tanstack/react-query';
import { getAuthToken } from 'lib/client';
export default function TeamAddForm({ onSave, onClose }) {
const { post } = useApi(getAuthToken());
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,34 @@
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
import { useMutation } from '@tanstack/react-query';
import { useRef } from 'react';
import { useApi } from 'next-basics';
import { getAuthToken } from 'lib/client';
export default function TeamEditForm({ teamId, data, onSave }) {
const { post } = useApi(getAuthToken());
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 useConfig from 'hooks/useConfig';
import { useRef } from 'react';
import { Form, FormRow, TextArea } from 'react-basics';
export default function TrackingCodeForm({ values, onClose }) { export default function TrackingCodeForm({ websiteId }) {
const ref = useRef(); const ref = useRef(null);
const { basePath } = useRouter();
const { trackerScriptName } = useConfig(); const { trackerScriptName } = useConfig();
const code = `<script async defer src="${trackerScriptName}" data-website-id="${websiteId}"></script>`;
return ( return (
<FormLayout> <>
<p> <Form ref={ref}>
<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> <FormRow>
<textarea <p>
ref={ref} To track stats for this website, place the following code in the{' '}
rows={3} <code>&lt;head&gt;</code> section of your HTML.
cols={60} </p>
spellCheck={false} <TextArea rows={4} value={code} readOnly allowCopy />
defaultValue={`<script async defer data-website-id="${values.id}" src="${
document.location.origin
}${basePath}/${trackerScriptName ? `${trackerScriptName}.js` : 'umami.js'}"></script>`}
readOnly
/>
</FormRow> </FormRow>
<FormButtons> </Form>
<CopyButton type="submit" variant="action" element={ref} /> </>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>
); );
} }

View File

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

View File

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

View File

@ -0,0 +1,81 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import { useApi } from 'next-basics';
import { useMutation } from '@tanstack/react-query';
import { getAuthToken } from 'lib/client';
import styles from './UserPasswordForm.module.css';
import useUser from 'hooks/useUser';
export default function UserPasswordForm({ onSave, userId }) {
const {
user: { id },
} = useUser();
const isCurrentUser = !userId || id === userId;
const url = isCurrentUser ? `/users/${id}/password` : `/users/${id}`;
const { post } = useApi(getAuthToken());
const { mutate, error, isLoading } = useMutation(data => post(url, data));
const ref = useRef(null);
const handleSubmit = async data => {
const payload = isCurrentUser
? data
: {
password: data.new_password,
};
mutate(payload, {
onSuccess: async () => {
onSave();
ref.current.reset();
},
});
};
const samePassword = value => {
if (value !== ref?.current?.getValues('new_password')) {
return "Passwords don't match";
}
return true;
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
{isCurrentUser && (
<FormInput
name="current_password"
label="Current password"
rules={{ required: 'Required' }}
>
<PasswordField autoComplete="off" />
</FormInput>
)}
<FormInput
name="new_password"
label="New password"
rules={{
required: 'Required',
minLength: { value: 8, message: 'Minimum length 8 characters' },
}}
>
<PasswordField autoComplete="off" />
</FormInput>
<FormInput
name="confirm_password"
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" disabled={isLoading}>
Save
</Button>
</FormButtons>
</Form>
);
}

View File

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

View File

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

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,45 @@
import { useMutation } from '@tanstack/react-query';
import { getAuthToken } from 'lib/client';
import { useApi } from 'next-basics';
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(getAuthToken());
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,19 +1,18 @@
import { Row, Column } from 'react-basics'; import Logo from 'assets/logo.svg';
import { useRouter } from 'next/router'; import HamburgerButton from 'components/common/HamburgerButton';
import { FormattedMessage } from 'react-intl';
import Link from 'components/common/Link'; 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 LanguageButton from 'components/settings/LanguageButton';
import ThemeButton from 'components/settings/ThemeButton'; 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 UserButton from 'components/settings/UserButton';
import { HOMEPAGE_URL } from 'lib/constants';
import useConfig from 'hooks/useConfig'; import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser'; import useUser from 'hooks/useUser';
import Logo from 'assets/logo.svg'; import { HOMEPAGE_URL } from 'lib/constants';
import styles from './Header.module.css'; import { useRouter } from 'next/router';
import { Column, Icon, Row } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import SettingsButton from '../settings/SettingsButton'; import SettingsButton from '../settings/SettingsButton';
import styles from './Header.module.css';
export default function Header() { export default function Header() {
const { user } = useUser(); const { user } = useUser();
@ -28,7 +27,9 @@ export default function Header() {
<header className={styles.header}> <header className={styles.header}>
<Row> <Row>
<Column className={styles.title}> <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> <Link href={isSharePage ? HOMEPAGE_URL : '/'}>umami</Link>
</Column> </Column>
<HamburgerButton /> <HamburgerButton />
@ -40,7 +41,7 @@ export default function Header() {
<Link href="/realtime"> <Link href="/realtime">
<FormattedMessage id="label.realtime" defaultMessage="Realtime" /> <FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</Link> </Link>
<Link href="/settings"> <Link href="/websites">
<FormattedMessage id="label.settings" defaultMessage="Settings" /> <FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link> </Link>
</div> </div>

View File

@ -1,6 +1,15 @@
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './Page.module.css'; 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>; return <div className={classNames(styles.page, className)}>{children}</div>;
} }

View File

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

View File

@ -1,7 +1,25 @@
import React from 'react'; import React from 'react';
import Link from 'next/link';
import classNames from 'classnames'; import classNames from 'classnames';
import { Button, Icon } from 'react-basics';
import styles from './PageHeader.module.css'; import styles from './PageHeader.module.css';
export default function PageHeader({ children, className }) { export default function PageHeader({ title, backUrl, children, className, style }) {
return <div className={classNames(styles.header, className)}>{children}</div>; 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; justify-content: space-between;
align-items: center; align-items: center;
align-content: center; align-content: center;
min-height: 80px;
align-self: stretch; align-self: stretch;
margin-bottom: 40px;
font-size: 18px;
font-weight: bold; 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,7 +1,7 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics'; import { safeDecodeURI } from 'next-basics';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import Times from 'assets/times.svg'; import Times from 'assets/times.svg';
import styles from './FilterTags.module.css'; import styles from './FilterTags.module.css';

View File

@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { FixedSizeList } from 'react-window'; import { FixedSizeList } from 'react-window';
import firstBy from 'thenby'; import firstBy from 'thenby';
import Icon from 'components/common/Icon'; import { Icon } from 'react-basics';
import Dot from 'components/common/Dot'; import Dot from 'components/common/Dot';
import FilterButtons from 'components/common/FilterButtons'; import FilterButtons from 'components/common/FilterButtons';
import NoData from 'components/common/NoData'; import NoData from 'components/common/NoData';

45
components/nav/Nav.js Normal file
View File

@ -0,0 +1,45 @@
import User from 'assets/user.svg';
import Team from 'assets/users.svg';
import Website from 'assets/website.svg';
import classNames from 'classnames';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Icon, Item, Menu, Text } from 'react-basics';
import styles from './Nav.module.css';
import useRequireLogin from 'hooks/useRequireLogin';
export default function Nav() {
const {
user: { role },
} = useRequireLogin();
const { pathname } = useRouter();
const handleSelect = () => {};
const items = [
{ icon: <Website />, label: 'Websites', url: '/websites' },
{ icon: <User />, label: 'Users', url: '/users', hidden: role !== 'admin' },
{ icon: <Team />, label: 'Teams', url: '/teams' },
{ icon: <User />, label: 'Profile', url: '/profile' },
];
return (
<Menu items={items} onSelect={handleSelect} className={styles.menu}>
{({ icon, label, url, hidden }) =>
!hidden && (
<Item
key={label}
className={classNames(styles.item, { [styles.selected]: pathname.startsWith(url) })}
>
<Link href={url}>
<a>
<Icon size="lg">{icon}</Icon>
<Text>{label}</Text>
</a>
</Link>
</Item>
)
}
</Menu>
);
}

View File

@ -0,0 +1,46 @@
.menu {
display: flex;
flex-direction: column;
width: 200px;
gap: 10px;
background: transparent;
margin-right: 16px;
}
.menu svg {
width: 20px;
height: 20px;
}
.item {
display: flex;
align-items: center;
gap: 20px;
font-weight: 600;
background: transparent;
padding: 0;
border-radius: 8px;
}
.item:hover {
background: var(--base100);
}
.item a {
color: var(--base700);
display: flex;
align-items: center;
gap: 20px;
flex: 1;
padding: 16px;
border-radius: 8px;
}
.item a:hover {
color: var(--base900);
}
.item.selected a {
color: var(--base900);
background: var(--base100);
}

View File

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import Page from 'components/layout/Page'; import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import WebsiteList from 'components/pages/WebsiteList'; import WebsiteList from 'components/pages/WebsiteList';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import DashboardSettingsButton from 'components/settings/DashboardSettingsButton'; import DashboardSettingsButton from 'components/settings/DashboardSettingsButton';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import useDashboard from 'store/dashboard'; import useDashboard from 'store/dashboard';

View File

@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import { firstBy } from 'thenby'; import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard'; import useDashboard, { saveDashboard } from 'store/dashboard';
import styles from './DashboardEdit.module.css'; import styles from './DashboardEdit.module.css';

View File

@ -0,0 +1,32 @@
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import ProfileDetails from 'components/settings/ProfileDetails';
import { useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast } from 'react-basics';
import UserPasswordForm from 'components/forms/UserPasswordForm';
export default function ProfileSettings() {
const [tab, setTab] = useState('general');
const { toast, showToast } = useToast();
const handleSave = () => {
showToast({ message: 'Saved successfully.', variant: 'success' });
};
return (
<Page>
{toast}
<PageHeader>
<Breadcrumbs>
<Item>Profile</Item>
</Breadcrumbs>
</PageHeader>
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
<Item key="general">General</Item>
<Item key="password">Password</Item>
</Tabs>
{tab === 'general' && <ProfileDetails />}
{tab === 'password' && <UserPasswordForm onSave={handleSave} />}
</Page>
);
}

View File

@ -1,50 +1,23 @@
import React, { useState } from 'react'; import Layout from 'components/layout/Layout';
import { FormattedMessage } from 'react-intl'; import Menu from 'components/nav/Nav';
import { useRouter } from 'next/router'; import useRequireLogin from 'hooks/useRequireLogin';
import Page from 'components/layout/Page'; import styles from './Settings.module.css';
import MenuLayout from 'components/layout/MenuLayout';
import WebsiteSettings from 'components/settings/WebsiteSettings';
import UserSettings from 'components/settings/UserSettings';
import ProfileSettings from 'components/settings/ProfileSettings';
import useUser from 'hooks/useUser';
const WEBSITES = '/settings'; export default function Settings({ children }) {
const ACCOUNTS = '/settings/users'; const { user: loggedIn } = useRequireLogin();
const PROFILE = '/settings/profile';
export default function Settings() { if (!loggedIn) {
const { user } = useUser();
const [option, setOption] = useState(WEBSITES);
const router = useRouter();
const { pathname } = router;
if (!user) {
return null; return null;
} }
const menuOptions = [
{
label: <FormattedMessage id="label.websites" defaultMessage="Websites" />,
value: WEBSITES,
},
{
label: <FormattedMessage id="label.users" defaultMessage="Users" />,
value: ACCOUNTS,
hidden: !user?.isAdmin,
},
{
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
value: PROFILE,
},
];
return ( return (
<Page> <Layout>
<MenuLayout menu={menuOptions} selectedOption={option} onMenuSelect={setOption}> <div className={styles.dashboard}>
{pathname === WEBSITES && <WebsiteSettings />} <div className={styles.nav}>
{pathname === ACCOUNTS && <UserSettings />} <Menu />
{pathname === PROFILE && <ProfileSettings />} </div>
</MenuLayout> <div className={styles.content}>{children}</div>
</Page> </div>
</Layout>
); );
} }

View File

@ -0,0 +1,16 @@
.dashboard {
display: flex;
flex: 1;
}
.nav {
margin-top: 20px;
}
.content {
position: relative;
background: var(--base50);
flex: 1;
border-radius: 8px;
overflow: hidden;
}

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast } from 'react-basics';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'next-basics';
import Link from 'next/link';
import Page from 'components/layout/Page';
import TeamEditForm from 'components/forms/TeamEditForm';
import PageHeader from 'components/layout/PageHeader';
import { getAuthToken } from 'lib/client';
import TeamMembersTable from '../tables/TeamMembersTable';
export default function TeamDetails({ teamId }) {
const [values, setValues] = useState(null);
const [tab, setTab] = useState('general');
const { get } = useApi(getAuthToken());
const { toast, showToast } = useToast();
const { data, isLoading } = useQuery(
['team', teamId],
() => {
if (teamId) {
return get(`/teams/${teamId}`);
}
},
{ cacheTime: 0 },
);
const handleSave = data => {
showToast({ message: 'Saved successfully.', variant: 'success' });
setValues(state => ({ ...state, ...data }));
};
useEffect(() => {
if (data) {
setValues(data);
}
}, [data]);
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader>
<Breadcrumbs>
<Item>
<Link href="/teams">Teams</Link>
</Item>
<Item>{values?.name}</Item>
</Breadcrumbs>
</PageHeader>
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
<Item key="general">General</Item>
<Item key="members">Members</Item>
<Item key="websites">Websites</Item>
</Tabs>
{tab === 'general' && <TeamEditForm teamId={teamId} data={values} onSave={handleSave} />}
{tab === 'members' && <TeamMembersTable teamId={teamId} />}
</Page>
);
}

View File

@ -0,0 +1,65 @@
import { useState } from 'react';
import { Button, Icon, Modal, useToast, Flexbox } from 'react-basics';
import { useApi } from 'next-basics';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import TeamAddForm from 'components/forms/TeamAddForm';
import PageHeader from 'components/layout/PageHeader';
import TeamsTable from 'components/tables/TeamsTable';
import Page from 'components/layout/Page';
import { getAuthToken } from 'lib/client';
import { useQuery } from '@tanstack/react-query';
export default function TeamsList() {
const [edit, setEdit] = useState(false);
const [update, setUpdate] = useState(0);
const { get } = useApi(getAuthToken());
const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`));
const hasData = data && data.length !== 0;
const { toast, showToast } = useToast();
const columns = [
{ name: 'name', label: 'Name', style: { flex: 2 } },
{ name: 'action', label: ' ' },
];
const handleAdd = () => {
setEdit(true);
};
const handleSave = () => {
setEdit(false);
setUpdate(state => state + 1);
showToast({ message: 'Team saved.', variant: 'success' });
};
const handleClose = () => {
setEdit(false);
};
return (
<Page loading={isLoading} error={error}>
{toast}
<PageHeader title="Teams">
<Button onClick={handleAdd}>
<Icon icon="plus" /> Create team
</Button>
</PageHeader>
{hasData && <TeamsTable columns={columns} rows={data} />}
{!hasData && (
<EmptyPlaceholder msg="You don't have any teams configured.">
<Flexbox justifyContent="center" alignItems="center">
<Button variant="primary" onClick={handleAdd}>
<Icon icon="plus" /> Create team
</Button>
</Flexbox>
</EmptyPlaceholder>
)}
{edit && (
<Modal title="Create team" onClose={handleClose}>
{close => <TeamAddForm onSave={handleSave} onClose={close} />}
</Modal>
)}
</Page>
);
}

View File

@ -1,14 +1,13 @@
import { Row, Column } from 'react-basics'; import DropDown from 'components/common/DropDown';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EventsChart from 'components/metrics/EventsChart';
import WebsiteChart from 'components/metrics/WebsiteChart';
import useFetch from 'hooks/useFetch';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Page from 'components/layout/Page'; import { Button, Column, Row } from 'react-basics';
import PageHeader from 'components/layout/PageHeader';
import DropDown from 'components/common/DropDown';
import WebsiteChart from 'components/metrics/WebsiteChart';
import EventsChart from 'components/metrics/EventsChart';
import Button from 'components/common/Button';
import useFetch from 'hooks/useFetch';
import styles from './TestConsole.module.css'; import styles from './TestConsole.module.css';
export default function TestConsole() { export default function TestConsole() {

View File

@ -0,0 +1,30 @@
import UserDeleteForm from 'components/forms/UserDeleteForm';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { Button, Form, FormRow, Modal } from 'react-basics';
export default function UserDelete({ userId, onSave }) {
const [modal, setModal] = useState(null);
const router = useRouter();
const handleDelete = async () => {
onSave();
await router.push('/users');
};
const handleClose = () => setModal(null);
return (
<Form>
<FormRow label="Delete user">
<p>All user data will be deleted.</p>
<Button onClick={() => setModal('delete')}>Delete</Button>
</FormRow>
{modal === 'delete' && (
<Modal title="Delete user" onClose={handleClose}>
{close => <UserDeleteForm userId={userId} onSave={handleDelete} onClose={close} />}
</Modal>
)}
</Form>
);
}

View File

@ -0,0 +1,69 @@
import { useQuery } from '@tanstack/react-query';
import UserDelete from 'components/pages/UserDelete';
import UserEditForm from 'components/forms/UserEditForm';
import UserPasswordForm from 'components/forms/UserPasswordForm';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import { getAuthToken } from 'lib/client';
import { useApi } from 'next-basics';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast } from 'react-basics';
export default function UserSettings({ userId }) {
const [values, setValues] = useState(null);
const [tab, setTab] = useState('general');
const { get } = useApi(getAuthToken());
const { toast, showToast } = useToast();
const router = useRouter();
const { data, isLoading } = useQuery(
['user', userId],
() => {
if (userId) {
return get(`/users/${userId}`);
}
},
{ cacheTime: 0 },
);
const handleSave = data => {
showToast({ message: 'Saved successfully.', variant: 'success' });
if (data) {
setValues(state => ({ ...state, ...data }));
}
};
const handleDelete = async () => {
showToast({ message: 'Deleted successfully.', variant: 'danger' });
await router.push('/users');
};
useEffect(() => {
if (data) {
setValues(data);
}
}, [data]);
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader>
<Breadcrumbs>
<Item>
<Link href="/users">Users</Link>
</Item>
<Item>{values?.username}</Item>
</Breadcrumbs>
</PageHeader>
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
<Item key="general">General</Item>
<Item key="password">Password</Item>
<Item key="delete">Danger Zone</Item>
</Tabs>
{tab === 'general' && <UserEditForm userId={userId} data={values} onSave={handleSave} />}
{tab === 'password' && <UserPasswordForm userId={userId} data={values} onSave={handleSave} />}
{tab === 'delete' && <UserDelete userId={userId} onSave={handleDelete} />}
</Page>
);
}

View File

@ -0,0 +1,45 @@
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import UsersTable from 'components/tables/UsersTable';
import { useState } from 'react';
import { Button, Icon, useToast } from 'react-basics';
import { getAuthToken } from 'lib/client';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'next-basics';
export default function UsersList() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const { toast, showToast } = useToast();
const { post } = useApi(getAuthToken());
const { mutate, isLoading } = useMutation(data => post('/api-key', data));
const handleSave = () => {
mutate(
{},
{
onSuccess: async () => {
showToast({ message: 'API key saved.', variant: 'success' });
},
},
);
};
return (
<Page loading={loading || isLoading} error={error}>
{toast}
<PageHeader title="Users">
<Button onClick={handleSave}>
<Icon icon="plus" /> Create user
</Button>
</PageHeader>
<UsersTable
onLoading={({ isLoading, error }) => {
setLoading(isLoading);
setError(error);
}}
onAddKeyClick={handleSave}
/>
</Page>
);
}

View File

@ -36,7 +36,7 @@ export default function WebsiteList({ websites, showCharts, limit }) {
return ( return (
<Page> <Page>
<EmptyPlaceholder msg={formatMessage(messages.noWebsites)}> <EmptyPlaceholder msg={formatMessage(messages.noWebsites)}>
<Link href="/settings" icon={<Arrow />} iconRight> <Link href="/websites" icon={<Arrow />} iconRight>
{formatMessage(messages.goToSettngs)} {formatMessage(messages.goToSettngs)}
</Link> </Link>
</EmptyPlaceholder> </EmptyPlaceholder>

View File

@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { Breadcrumbs, Item, Tabs, useToast, Button, Icon } from 'react-basics';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'next-basics';
import Link from 'next/link';
import Page from 'components/layout/Page';
import WebsiteEditForm from 'components/forms/WebsiteEditForm';
import WebsiteReset from 'components/forms/WebsiteReset';
import PageHeader from 'components/layout/PageHeader';
import TrackingCodeForm from 'components/forms/TrackingCodeForm';
import ShareUrlForm from 'components/forms/ShareUrlForm';
import { getAuthToken } from 'lib/client';
import ExternalLink from 'assets/external-link.svg';
export default function Websites({ websiteId }) {
const [values, setValues] = useState(null);
const [tab, setTab] = useState('general');
const { get } = useApi(getAuthToken());
const { toast, showToast } = useToast();
const { data, isLoading } = useQuery(
['website', websiteId],
() => {
if (websiteId) {
return get(`/websites/${websiteId}`);
}
},
{ cacheTime: 0 },
);
const handleSave = data => {
showToast({ message: 'Saved successfully.', variant: 'success' });
setValues(state => ({ ...state, ...data }));
};
useEffect(() => {
if (data) {
setValues(data);
}
}, [data]);
return (
<Page loading={isLoading || !values}>
{toast}
<PageHeader>
<Breadcrumbs>
<Item>
<Link href="/websites">Websites</Link>
</Item>
<Item>{values?.name}</Item>
</Breadcrumbs>
<Link href={`/analytics/websites/${websiteId}`}>
<a target="_blank">
<Button variant="primary">
<Icon>
<ExternalLink />
</Icon>
View
</Button>
</a>
</Link>
</PageHeader>
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
<Item key="general">General</Item>
<Item key="tracking">Tracking code</Item>
<Item key="share">Share URL</Item>
<Item key="danger">Danger zone</Item>
</Tabs>
{tab === 'general' && (
<WebsiteEditForm websiteId={websiteId} data={values} onSave={handleSave} />
)}
{tab === 'tracking' && <TrackingCodeForm websiteId={websiteId} data={values} />}
{tab === 'share' && <ShareUrlForm websiteId={websiteId} data={values} onSave={handleSave} />}
{tab === 'danger' && <WebsiteReset websiteId={websiteId} onSave={handleSave} />}
</Page>
);
}

View File

@ -0,0 +1,70 @@
import { useState } from 'react';
import { Button, Icon, Modal, useToast, Flexbox } from 'react-basics';
import { useApi } from 'next-basics';
import { useQuery } from '@tanstack/react-query';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import WebsiteAddForm from 'components/forms/WebsiteAddForm';
import PageHeader from 'components/layout/PageHeader';
import WebsitesTable from 'components/tables/WebsitesTable';
import Page from 'components/layout/Page';
import { getAuthToken } from 'lib/client';
import useUser from 'hooks/useUser';
export default function WebsitesList() {
const [edit, setEdit] = useState(false);
const [update, setUpdate] = useState(0);
const { get } = useApi(getAuthToken());
const { user } = useUser();
const { data, isLoading, error } = useQuery(['websites', update], () =>
get(`/users/${user.id}/websites`),
);
const hasData = data && data.length !== 0;
const { toast, showToast } = useToast();
const columns = [
{ name: 'name', label: 'Name', style: { flex: 2 } },
{ name: 'domain', label: 'Domain' },
{ name: 'action', label: ' ' },
];
const handleAdd = () => {
setEdit(true);
};
const handleSave = () => {
setEdit(false);
setUpdate(state => state + 1);
showToast({ message: 'Website saved.', variant: 'success' });
};
const handleClose = () => {
setEdit(false);
};
return (
<Page loading={isLoading} error={error}>
{toast}
<PageHeader title="Websites">
<Button onClick={handleAdd}>
<Icon icon="plus" /> Add website
</Button>
</PageHeader>
{hasData && <WebsitesTable columns={columns} rows={data} />}
{!hasData && (
<EmptyPlaceholder msg="You don't have any websites configured.">
<Flexbox justifyContent="center" alignItems="center">
<Button variant="primary" onClick={handleAdd}>
<Icon icon="plus" /> Add website
</Button>
</Flexbox>
</EmptyPlaceholder>
)}
{edit && (
<Modal title="Add website" onClose={handleClose}>
{close => <WebsiteAddForm onSave={handleSave} onClose={close} />}
</Modal>
)}
</Page>
);
}

View File

@ -3,6 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import MenuButton from 'components/common/MenuButton'; import MenuButton from 'components/common/MenuButton';
import Gear from 'assets/gear.svg'; import Gear from 'assets/gear.svg';
import { saveDashboard } from 'store/dashboard'; import { saveDashboard } from 'store/dashboard';
import { Icon } from 'react-basics';
const messages = defineMessages({ const messages = defineMessages({
toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' }, toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' },
@ -32,5 +33,11 @@ export default function DashboardSettingsButton() {
} }
} }
return <MenuButton icon={<Gear />} options={menuOptions} onSelect={handleSelect} hideLabel />; return (
<MenuButton options={menuOptions} onSelect={handleSelect} hideLabel>
<Icon>
<Gear />
</Icon>
</MenuButton>
);
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import DateFilter, { filterOptions } from 'components/common/DateFilter'; import DateFilter, { filterOptions } from 'components/common/DateFilter';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import useDateRange from 'hooks/useDateRange'; import useDateRange from 'hooks/useDateRange';
import { DEFAULT_DATE_RANGE } from 'lib/constants'; import { DEFAULT_DATE_RANGE } from 'lib/constants';
import styles from './DateRangeSetting.module.css'; import styles from './DateRangeSetting.module.css';
@ -28,7 +28,7 @@ export default function DateRangeSetting() {
endDate={endDate} endDate={endDate}
onChange={handleChange} onChange={handleChange}
/> />
<Button className={styles.button} size="small" onClick={handleReset}> <Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" /> <FormattedMessage id="label.reset" defaultMessage="Reset" />
</Button> </Button>
</> </>

View File

@ -4,6 +4,7 @@ import useLocale from 'hooks/useLocale';
import MenuButton from 'components/common/MenuButton'; import MenuButton from 'components/common/MenuButton';
import Globe from 'assets/globe.svg'; import Globe from 'assets/globe.svg';
import styles from './LanguageButton.module.css'; import styles from './LanguageButton.module.css';
import { Icon } from 'react-basics';
export default function LanguageButton() { export default function LanguageButton() {
const { locale, saveLocale } = useLocale(); const { locale, saveLocale } = useLocale();
@ -15,13 +16,16 @@ export default function LanguageButton() {
return ( return (
<MenuButton <MenuButton
icon={<Globe />}
options={menuOptions} options={menuOptions}
value={locale} value={locale}
menuClassName={styles.menu} menuClassName={styles.menu}
buttonVariant="light" buttonVariant="light"
onSelect={handleSelect} onSelect={handleSelect}
hideLabel hideLabel
/> >
<Icon>
<Globe />
</Icon>
</MenuButton>
); );
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import DropDown from 'components/common/DropDown'; import DropDown from 'components/common/DropDown';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import useLocale from 'hooks/useLocale'; import useLocale from 'hooks/useLocale';
import { DEFAULT_LOCALE } from 'lib/constants'; import { DEFAULT_LOCALE } from 'lib/constants';
import styles from './TimezoneSetting.module.css'; import styles from './TimezoneSetting.module.css';
@ -23,7 +23,7 @@ export default function LanguageSetting() {
options={options} options={options}
onChange={saveLocale} onChange={saveLocale}
/> />
<Button className={styles.button} size="small" onClick={handleReset}> <Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" /> <FormattedMessage id="label.reset" defaultMessage="Reset" />
</Button> </Button>
</> </>

View File

@ -0,0 +1,53 @@
import TimezoneSetting from 'components/settings/TimezoneSetting';
import useUser from 'hooks/useUser';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import DateRangeSetting from './DateRangeSetting';
import LanguageSetting from './LanguageSetting';
import styles from './ProfileSettings.module.css';
import ThemeSetting from './ThemeSetting';
export default function ProfileDetails() {
const { user } = useUser();
if (!user) {
return null;
}
const { username } = user;
return (
<>
<dl className={styles.list}>
<dt>
<FormattedMessage id="label.username" defaultMessage="Username" />
</dt>
<dd>{username}</dd>
<dt>
<FormattedMessage id="label.timezone" defaultMessage="Timezone" />
</dt>
<dd>
<TimezoneSetting />
</dd>
<dt>
<FormattedMessage id="label.default-date-range" defaultMessage="Default date range" />
</dt>
<dd>
<DateRangeSetting />
</dd>
<dt>
<FormattedMessage id="label.language" defaultMessage="Language" />
</dt>
<dd>
<LanguageSetting />
</dd>
<dt>
<FormattedMessage id="label.theme" defaultMessage="Theme" />
</dt>
<dd>
<ThemeSetting />
</dd>
</dl>
</>
);
}

View File

@ -1,91 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import PageHeader from 'components/layout/PageHeader';
import Button from 'components/common/Button';
import Modal from 'components/common/Modal';
import Toast from 'components/common/Toast';
import ChangePasswordForm from 'components/forms/ChangePasswordForm';
import TimezoneSetting from 'components/settings/TimezoneSetting';
import Dots from 'assets/ellipsis-h.svg';
import styles from './ProfileSettings.module.css';
import DateRangeSetting from './DateRangeSetting';
import useEscapeKey from 'hooks/useEscapeKey';
import useUser from 'hooks/useUser';
import LanguageSetting from './LanguageSetting';
import ThemeSetting from './ThemeSetting';
export default function ProfileSettings() {
const { user } = useUser();
const [changePassword, setChangePassword] = useState(false);
const [message, setMessage] = useState(null);
function handleSave() {
setChangePassword(false);
setMessage(<FormattedMessage id="message.save-success" defaultMessage="Saved successfully." />);
}
useEscapeKey(() => {
setChangePassword(false);
});
if (!user) {
return null;
}
const { userId, username } = user;
return (
<>
<PageHeader>
<div>
<FormattedMessage id="label.profile" defaultMessage="Profile" />
</div>
<Button icon={<Dots />} size="small" onClick={() => setChangePassword(true)}>
<FormattedMessage id="label.change-password" defaultMessage="Change password" />
</Button>
</PageHeader>
<dl className={styles.list}>
<dt>
<FormattedMessage id="label.username" defaultMessage="Username" />
</dt>
<dd>{username}</dd>
<dt>
<FormattedMessage id="label.timezone" defaultMessage="Timezone" />
</dt>
<dd>
<TimezoneSetting />
</dd>
<dt>
<FormattedMessage id="label.default-date-range" defaultMessage="Default date range" />
</dt>
<dd>
<DateRangeSetting />
</dd>
<dt>
<FormattedMessage id="label.language" defaultMessage="Language" />
</dt>
<dd>
<LanguageSetting />
</dd>
<dt>
<FormattedMessage id="label.theme" defaultMessage="Theme" />
</dt>
<dd>
<ThemeSetting />
</dd>
</dl>
{changePassword && (
<Modal
title={<FormattedMessage id="label.change-password" defaultMessage="Change password" />}
>
<ChangePasswordForm
values={{ userId }}
onSave={handleSave}
onClose={() => setChangePassword(false)}
/>
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

View File

@ -2,7 +2,7 @@ import React, { useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import TimezoneSetting from './TimezoneSetting'; import TimezoneSetting from './TimezoneSetting';
import DateRangeSetting from './DateRangeSetting'; import DateRangeSetting from './DateRangeSetting';
import Button from 'components/common/Button'; import { Button, Icon } from 'react-basics';
import styles from './SettingsButton.module.css'; import styles from './SettingsButton.module.css';
import Gear from 'assets/gear.svg'; import Gear from 'assets/gear.svg';
import useDocumentClick from '../../hooks/useDocumentClick'; import useDocumentClick from '../../hooks/useDocumentClick';
@ -23,7 +23,11 @@ export default function SettingsButton() {
return ( return (
<div className={styles.button} ref={ref}> <div className={styles.button} ref={ref}>
<Button icon={<Gear />} variant="light" onClick={handleClick} /> <Button variant="light" onClick={handleClick}>
<Icon>
<Gear />
</Icon>
</Button>
{show && ( {show && (
<div className={styles.panel}> <div className={styles.panel}>
<dt> <dt>

View File

@ -4,7 +4,7 @@ import useTheme from 'hooks/useTheme';
import Sun from 'assets/sun.svg'; import Sun from 'assets/sun.svg';
import Moon from 'assets/moon.svg'; import Moon from 'assets/moon.svg';
import styles from './ThemeButton.module.css'; import styles from './ThemeButton.module.css';
import Icon from '../common/Icon'; import { Icon } from 'react-basics';
export default function ThemeButton() { export default function ThemeButton() {
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
@ -30,7 +30,7 @@ export default function ThemeButton() {
<div className={styles.button} onClick={handleClick}> <div className={styles.button} onClick={handleClick}>
{transitions((styles, item) => ( {transitions((styles, item) => (
<animated.div key={item} style={styles}> <animated.div key={item} style={styles}>
<Icon icon={item === 'light' ? <Sun /> : <Moon />} /> <Icon>{item === 'light' ? <Sun /> : <Moon />}</Icon>
</animated.div> </animated.div>
))} ))}
</div> </div>

View File

@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import Button from 'components/common/Button'; import { Button, Icon } from 'react-basics';
import useTheme from 'hooks/useTheme'; import useTheme from 'hooks/useTheme';
import Sun from 'assets/sun.svg'; import Sun from 'assets/sun.svg';
import Moon from 'assets/moon.svg'; import Moon from 'assets/moon.svg';
@ -12,14 +12,20 @@ export default function ThemeSetting() {
<div className={styles.buttons}> <div className={styles.buttons}>
<Button <Button
className={classNames({ [styles.active]: theme === 'light' })} className={classNames({ [styles.active]: theme === 'light' })}
icon={<Sun />}
onClick={() => setTheme('light')} onClick={() => setTheme('light')}
/> >
<Icon>
<Sun />
</Icon>
</Button>
<Button <Button
className={classNames({ [styles.active]: theme === 'dark' })} className={classNames({ [styles.active]: theme === 'dark' })}
icon={<Moon />}
onClick={() => setTheme('dark')} onClick={() => setTheme('dark')}
/> >
<Icon>
<Moon />
</Icon>
</Button>
</div> </div>
); );
} }

View File

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { listTimeZones } from 'timezone-support'; import { listTimeZones } from 'timezone-support';
import DropDown from 'components/common/DropDown'; import DropDown from 'components/common/DropDown';
import Button from 'components/common/Button'; import { Button } from 'react-basics';
import useTimezone from 'hooks/useTimezone'; import useTimezone from 'hooks/useTimezone';
import { getTimezone } from 'lib/date'; import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css'; import styles from './TimezoneSetting.module.css';
@ -23,7 +23,7 @@ export default function TimezoneSetting() {
options={options} options={options}
onChange={saveTimezone} onChange={saveTimezone}
/> />
<Button className={styles.button} size="small" onClick={handleReset}> <Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" /> <FormattedMessage id="label.reset" defaultMessage="Reset" />
</Button> </Button>
</> </>

View File

@ -1,16 +1,18 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import { removeItem } from 'next-basics';
import MenuButton from 'components/common/MenuButton';
import Icon from 'components/common/Icon';
import User from 'assets/user.svg'; import User from 'assets/user.svg';
import styles from './UserButton.module.css';
import { AUTH_TOKEN } from 'lib/constants';
import useUser from 'hooks/useUser';
import useConfig from 'hooks/useConfig'; import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser';
import { AUTH_TOKEN } from 'lib/constants';
import { removeItem } from 'next-basics';
import { useRouter } from 'next/router';
import React, { useRef, useState } from 'react';
import { Button, Icon, Item, Menu, Popup, Text } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import styles from './UserButton.module.css';
import useDocumentClick from '../../hooks/useDocumentClick';
export default function UserButton() { export default function UserButton() {
const [show, setShow] = useState(false);
const ref = useRef();
const { user } = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
const { adminDisabled } = useConfig(); const { adminDisabled } = useConfig();
@ -31,26 +33,48 @@ export default function UserButton() {
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />, label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
value: 'profile', value: 'profile',
hidden: adminDisabled, hidden: adminDisabled,
divider: true,
}, },
{ label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: 'logout' }, { label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: 'logout' },
]; ];
function handleClick() {
setShow(state => !state);
}
function handleSelect(value) { function handleSelect(value) {
if (value === 'logout') { if (value === 'logout') {
removeItem(AUTH_TOKEN); removeItem(AUTH_TOKEN);
router.push('/login'); router.push('/login');
} else if (value === 'profile') { } else if (value === 'profile') {
router.push('/settings/profile'); router.push('/profile');
} }
} }
useDocumentClick(e => {
if (!ref.current?.contains(e.target)) {
setShow(false);
}
});
return ( return (
<MenuButton <div className={styles.button} ref={ref}>
icon={<Icon icon={<User />} size="large" />} <Button variant="light" onClick={handleClick}>
buttonVariant="light" <Icon className={styles.icon} size="large">
options={menuOptions} <User />
onSelect={handleSelect} </Icon>
hideLabel </Button>
/> {show && (
<Popup className={styles.menu} position="bottom" gap={5}>
<Menu items={menuOptions} onSelect={handleSelect}>
{({ label, value }) => (
<Item key={value}>
<Text>{label}</Text>
</Item>
)}
</Menu>
</Popup>
)}
</div>
); );
} }

View File

@ -1,3 +1,7 @@
.button {
position: relative;
}
.username { .username {
border-bottom: 1px solid var(--base500); border-bottom: 1px solid var(--base500);
} }
@ -5,3 +9,18 @@
.username:hover { .username:hover {
background: var(--base50); background: var(--base50);
} }
.icon svg {
font-size: 16px;
height: 16px;
width: 16px;
}
.menu {
left: -50%;
background: var(--base50);
border: 1px solid var(--base500);
border-radius: 4px;
overflow: hidden;
z-index: 100;
}

View File

@ -1,133 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import Link from 'next/link';
import classNames from 'classnames';
import PageHeader from 'components/layout/PageHeader';
import Button from 'components/common/Button';
import Icon from 'components/common/Icon';
import Table from 'components/common/Table';
import Modal from 'components/common/Modal';
import Toast from 'components/common/Toast';
import UserEditForm from 'components/forms/UserEditForm';
import ButtonLayout from 'components/layout/ButtonLayout';
import DeleteForm from 'components/forms/DeleteForm';
import useFetch from 'hooks/useFetch';
import Pen from 'assets/pen.svg';
import Plus from 'assets/plus.svg';
import Trash from 'assets/trash.svg';
import Check from 'assets/check.svg';
import LinkIcon from 'assets/external-link.svg';
import styles from './UserSettings.module.css';
export default function UserSettings() {
const [addUser, setAddUser] = useState();
const [editUser, setEditUser] = useState();
const [deleteUser, setDeleteUser] = useState();
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch(`/users`, {}, [saved]);
const Checkmark = ({ isAdmin }) => (isAdmin ? <Icon icon={<Check />} size="medium" /> : null);
const DashboardLink = row => {
return (
<Link href={`/dashboard/${row.id}/${row.username}`}>
<a>
<Icon icon={<LinkIcon />} />
</a>
</Link>
);
};
const Buttons = row => (
<ButtonLayout align="right">
<Button icon={<Pen />} size="small" onClick={() => setEditUser(row)}>
<FormattedMessage id="label.edit" defaultMessage="Edit" />
</Button>
{!row.isAdmin && (
<Button icon={<Trash />} size="small" onClick={() => setDeleteUser(row)}>
<FormattedMessage id="label.delete" defaultMessage="Delete" />
</Button>
)}
</ButtonLayout>
);
const columns = [
{
key: 'username',
label: <FormattedMessage id="label.username" defaultMessage="Username" />,
className: 'col-12 col-lg-4',
},
{
key: 'isAdmin',
label: <FormattedMessage id="label.administrator" defaultMessage="Administrator" />,
className: 'col-12 col-lg-3',
render: Checkmark,
},
{
key: 'dashboard',
label: <FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />,
className: 'col-12 col-lg-3',
render: DashboardLink,
},
{
key: 'actions',
className: classNames(styles.buttons, 'col-12 col-lg-2 pt-2 pt-md-0'),
render: Buttons,
},
];
function handleSave() {
setSaved(state => state + 1);
setMessage(<FormattedMessage id="message.save-success" defaultMessage="Saved successfully." />);
handleClose();
}
function handleClose() {
setEditUser(null);
setAddUser(null);
setDeleteUser(null);
}
if (!data) {
return null;
}
return (
<>
<PageHeader>
<div>
<FormattedMessage id="label.users" defaultMessage="Users" />
</div>
<Button icon={<Plus />} size="small" onClick={() => setAddUser(true)}>
<FormattedMessage id="label.add-user" defaultMessage="Add user" />
</Button>
</PageHeader>
<Table columns={columns} rows={data} />
{editUser && (
<Modal title={<FormattedMessage id="label.edit-user" defaultMessage="Edit user" />}>
<UserEditForm
values={{ ...editUser, password: '' }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{addUser && (
<Modal title={<FormattedMessage id="label.add-user" defaultMessage="Add user" />}>
<UserEditForm onSave={handleSave} onClose={handleClose} />
</Modal>
)}
{deleteUser && (
<Modal title={<FormattedMessage id="label.delete-user" defaultMessage="Delete user" />}>
<DeleteForm
values={{ type: 'users', id: deleteUser.id, name: deleteUser.username }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

View File

@ -1,5 +0,0 @@
.buttons {
display: flex;
justify-content: flex-end;
flex: 1;
}

View File

@ -1,232 +0,0 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Link from 'components/common/Link';
import Table from 'components/common/Table';
import Button from 'components/common/Button';
import OverflowText from 'components/common/OverflowText';
import PageHeader from 'components/layout/PageHeader';
import Modal from 'components/common/Modal';
import WebsiteEditForm from 'components/forms/WebsiteEditForm';
import ResetForm from 'components/forms/ResetForm';
import DeleteForm from 'components/forms/DeleteForm';
import TrackingCodeForm from 'components/forms/TrackingCodeForm';
import ShareUrlForm from 'components/forms/ShareUrlForm';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import ButtonLayout from 'components/layout/ButtonLayout';
import Toast from 'components/common/Toast';
import Favicon from 'components/common/Favicon';
import Pen from 'assets/pen.svg';
import Trash from 'assets/trash.svg';
import Reset from 'assets/redo.svg';
import Plus from 'assets/plus.svg';
import Code from 'assets/code.svg';
import LinkIcon from 'assets/link.svg';
import useFetch from 'hooks/useFetch';
import useUser from 'hooks/useUser';
import styles from './WebsiteSettings.module.css';
export default function WebsiteSettings() {
const { user } = useUser();
const [editWebsite, setEditWebsite] = useState();
const [resetWebsite, setResetWebsite] = useState();
const [deleteWebsite, setDeleteWebsite] = useState();
const [addWebsite, setAddWebsite] = useState();
const [showCode, setShowCode] = useState();
const [showUrl, setShowUrl] = useState();
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch('/websites', { params: { include_all: !!user?.isAdmin } }, [saved]);
const Buttons = row => (
<ButtonLayout align="right">
{row.shareId && (
<Button
icon={<LinkIcon />}
size="small"
tooltip={<FormattedMessage id="message.get-share-url" defaultMessage="Get share URL" />}
tooltipId={`button-share-${row.id}`}
onClick={() => setShowUrl(row)}
/>
)}
<Button
icon={<Code />}
size="small"
tooltip={
<FormattedMessage id="message.get-tracking-code" defaultMessage="Get tracking code" />
}
tooltipId={`button-code-${row.id}`}
onClick={() => setShowCode(row)}
/>
<Button
icon={<Pen />}
size="small"
tooltip={<FormattedMessage id="label.edit" defaultMessage="Edit" />}
tooltipId={`button-edit-${row.id}`}
onClick={() => setEditWebsite(row)}
/>
<Button
icon={<Reset />}
size="small"
tooltip={<FormattedMessage id="label.reset" defaultMessage="Reset" />}
tooltipId={`button-reset-${row.id}`}
onClick={() => setResetWebsite(row)}
/>
<Button
icon={<Trash />}
size="small"
tooltip={<FormattedMessage id="label.delete" defaultMessage="Delete" />}
tooltipId={`button-delete-${row.id}`}
onClick={() => setDeleteWebsite(row)}
/>
</ButtonLayout>
);
const DetailsLink = ({ id, name, domain }) => (
<Link className={styles.detailLink} href="/websites/[...id]" as={`/websites/${id}/${name}`}>
<Favicon domain={domain} />
<OverflowText tooltipId={`${id}-name`}>{name}</OverflowText>
</Link>
);
const Domain = ({ domain, id }) => (
<OverflowText tooltipId={`${id}-domain`}>{domain}</OverflowText>
);
const adminColumns = [
{
key: 'name',
label: <FormattedMessage id="label.name" defaultMessage="Name" />,
className: 'col-12 col-lg-4 col-xl-3',
render: DetailsLink,
},
{
key: 'domain',
label: <FormattedMessage id="label.domain" defaultMessage="Domain" />,
className: 'col-12 col-lg-4 col-xl-3',
render: Domain,
},
{
key: 'user',
label: <FormattedMessage id="label.owner" defaultMessage="Owner" />,
className: 'col-12 col-lg-4 col-xl-1',
},
{
key: 'action',
className: classNames(styles.buttons, 'col-12 col-xl-5 pt-2 pt-xl-0'),
render: Buttons,
},
];
const columns = [
{
key: 'name',
label: <FormattedMessage id="label.name" defaultMessage="Name" />,
className: 'col-12 col-lg-6 col-xl-4',
render: DetailsLink,
},
{
key: 'domain',
label: <FormattedMessage id="label.domain" defaultMessage="Domain" />,
className: 'col-12 col-lg-6 col-xl-4',
render: Domain,
},
{
key: 'action',
className: classNames(styles.buttons, 'col-12 col-xl-4 pt-2 pt-xl-0'),
render: Buttons,
},
];
function handleSave() {
setSaved(state => state + 1);
setMessage(<FormattedMessage id="message.save-success" defaultMessage="Saved successfully." />);
handleClose();
}
function handleClose() {
setAddWebsite(null);
setEditWebsite(null);
setResetWebsite(null);
setDeleteWebsite(null);
setShowCode(null);
setShowUrl(null);
}
if (!data) {
return null;
}
const empty = (
<EmptyPlaceholder
msg={
<FormattedMessage
id="message.no-websites-configured"
defaultMessage="You don't have any websites configured."
/>
}
>
<Button icon={<Plus />} size="medium" onClick={() => setAddWebsite(true)}>
<FormattedMessage id="label.add-website" defaultMessage="Add website" />
</Button>
</EmptyPlaceholder>
);
return (
<>
<PageHeader>
<div>
<FormattedMessage id="label.websites" defaultMessage="Websites" />
</div>
<Button icon={<Plus />} size="small" onClick={() => setAddWebsite(true)}>
<FormattedMessage id="label.add-website" defaultMessage="Add website" />
</Button>
</PageHeader>
<Table columns={user.isAdmin ? adminColumns : columns} rows={data} empty={empty} />
{editWebsite && (
<Modal title={<FormattedMessage id="label.edit-website" defaultMessage="Edit website" />}>
<WebsiteEditForm values={editWebsite} onSave={handleSave} onClose={handleClose} />
</Modal>
)}
{addWebsite && (
<Modal title={<FormattedMessage id="label.add-website" defaultMessage="Add website" />}>
<WebsiteEditForm onSave={handleSave} onClose={handleClose} />
</Modal>
)}
{resetWebsite && (
<Modal
title={<FormattedMessage id="label.reset-website" defaultMessage="Reset statistics" />}
>
<ResetForm
values={{ type: 'websites', id: resetWebsite.id, name: resetWebsite.name }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{deleteWebsite && (
<Modal
title={<FormattedMessage id="label.delete-website" defaultMessage="Delete website" />}
>
<DeleteForm
values={{ type: 'websites', id: deleteWebsite.id, name: deleteWebsite.name }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{showCode && (
<Modal title={<FormattedMessage id="label.tracking-code" defaultMessage="Tracking code" />}>
<TrackingCodeForm values={showCode} onClose={handleClose} />
</Modal>
)}
{showUrl && (
<Modal title={<FormattedMessage id="label.share-url" defaultMessage="Share URL" />}>
<ShareUrlForm values={showUrl} onClose={handleClose} />
</Modal>
)}
{message && <Toast message={message} onClose={() => setMessage(null)} />}
</>
);
}

View File

@ -1,13 +0,0 @@
.col {
flex: 2;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
.detailLink {
width: 100%;
}

View File

@ -0,0 +1,62 @@
import Link from 'next/link';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
TableColumn,
Button,
Icon,
} from 'react-basics';
import styles from './TeamsTable.module.css';
export default function TeamMembersTable({ columns = [], rows = [] }) {
return (
<Table className={styles.table} columns={columns} rows={rows}>
<TableHeader>
{(column, index) => {
return (
<TableColumn key={index} style={{ ...column.style }}>
{column.label}
</TableColumn>
);
}}
</TableHeader>
<TableBody>
{(row, keys, rowIndex) => {
const { id } = row;
row.action = (
<div className={styles.actions}>
<Link href={`/teams/${id}`} shallow>
<a>
<Button>
<Icon icon="arrow-right" />
Settings
</Button>
</a>
</Link>
</div>
);
return (
<TableRow key={rowIndex} data={row} keys={keys}>
{(data, key, colIndex) => {
return (
<TableCell
key={colIndex}
className={styles.cell}
style={{ ...columns[colIndex]?.style }}
>
{data[key]}
</TableCell>
);
}}
</TableRow>
);
}}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,62 @@
import Link from 'next/link';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
TableColumn,
Button,
Icon,
} from 'react-basics';
import styles from './TeamsTable.module.css';
export default function TeamsTable({ columns = [], rows = [] }) {
return (
<Table className={styles.table} columns={columns} rows={rows}>
<TableHeader>
{(column, index) => {
return (
<TableColumn key={index} style={{ ...column.style }}>
{column.label}
</TableColumn>
);
}}
</TableHeader>
<TableBody>
{(row, keys, rowIndex) => {
const { id } = row;
row.action = (
<div className={styles.actions}>
<Link href={`/teams/${id}`} shallow>
<a>
<Button>
<Icon icon="arrow-right" />
Settings
</Button>
</a>
</Link>
</div>
);
return (
<TableRow key={rowIndex} data={row} keys={keys}>
{(data, key, colIndex) => {
return (
<TableCell
key={colIndex}
className={styles.cell}
style={{ ...columns[colIndex]?.style }}
>
{data[key]}
</TableCell>
);
}}
</TableRow>
);
}}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,18 @@
.table th,
.table td {
flex: 2;
}
.cell {
display: flex;
align-items: center;
}
.cell:last-child {
justify-content: flex-end;
}
.actions {
display: flex;
gap: 12px;
}

View File

@ -0,0 +1,100 @@
import { useQuery } from '@tanstack/react-query';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { formatDistance } from 'date-fns';
import { getAuthToken } from 'lib/client';
import { useApi } from 'next-basics';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import {
Button,
Icon,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from 'react-basics';
import styles from './UsersTable.module.css';
const defaultColumns = [
{ name: 'username', label: 'Username', style: { flex: 2 } },
{ name: 'role', label: 'Role', style: { flex: 2 } },
{ name: 'created', label: 'Created' },
{ name: 'action', label: ' ' },
];
export default function UsersTable({ columns = defaultColumns, onLoading, onAddKeyClick }) {
const [values, setValues] = useState(null);
const { get } = useApi(getAuthToken());
const { data, isLoading, error } = useQuery(['user'], () => get(`/users`));
const hasData = data && data.length !== 0;
useEffect(() => {
if (data) {
setValues(data);
onLoading({ data, isLoading, error });
}
}, [onLoading, data, isLoading, error]);
return (
<>
{hasData && (
<Table className={styles.table} columns={columns} rows={values}>
<TableHeader>
{(column, index) => {
return (
<TableColumn key={index} className={styles.header} style={{ ...column.style }}>
{column.label}
</TableColumn>
);
}}
</TableHeader>
<TableBody>
{(row, keys, rowIndex) => {
row.created = formatDistance(new Date(row.createdAt), new Date(), {
addSuffix: true,
});
row.action = (
<div className={styles.actions}>
<Link href={`/users/${row.id}`} shallow>
<a>
<Button>
<Icon icon="arrow-right" />
Settings
</Button>
</a>
</Link>
</div>
);
return (
<TableRow key={rowIndex} data={row} keys={keys}>
{(data, key, colIndex) => {
return (
<TableCell
key={colIndex}
className={styles.cell}
style={{ ...columns[colIndex]?.style }}
>
{data[key]}
</TableCell>
);
}}
</TableRow>
);
}}
</TableBody>
</Table>
)}
{!hasData && (
<EmptyPlaceholder className={styles.empty} msg="You don't have any Users.">
<Button variant="primary" onClick={onAddKeyClick}>
<Icon icon="plus" /> Create User
</Button>
</EmptyPlaceholder>
)}
</>
);
}

View File

@ -0,0 +1,26 @@
.table th,
.table td {
flex: 2;
}
.cell {
display: flex;
align-items: center;
}
.input {
flex: 2;
}
.cell:last-child {
justify-content: flex-end;
}
.actions {
display: flex;
gap: 12px;
}
.empty {
min-height: 300px;
}

View File

@ -0,0 +1,73 @@
import Link from 'next/link';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
TableColumn,
Button,
Icon,
} from 'react-basics';
import ExternalLink from 'assets/external-link.svg';
import styles from './WebsitesTable.module.css';
export default function WebsitesTable({ columns = [], rows = [] }) {
return (
<Table className={styles.table} columns={columns} rows={rows}>
<TableHeader>
{(column, index) => {
return (
<TableColumn key={index} style={{ ...column.style }}>
{column.label}
</TableColumn>
);
}}
</TableHeader>
<TableBody>
{(row, keys, rowIndex) => {
const { id } = row;
row.action = (
<div className={styles.actions}>
<Link href={`/websites/${id}/settings`} shallow>
<a>
<Button>
<Icon icon="arrow-right" />
Settings
</Button>
</a>
</Link>
<Link href={`/websites/${id}`}>
<a>
<Button>
<Icon>
<ExternalLink />
</Icon>
View
</Button>
</a>
</Link>
</div>
);
return (
<TableRow key={rowIndex} data={row} keys={keys}>
{(data, key, colIndex) => {
return (
<TableCell
key={colIndex}
className={styles.cell}
style={{ ...columns[colIndex]?.style }}
>
{data[key]}
</TableCell>
);
}}
</TableRow>
);
}}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,18 @@
.table th,
.table td {
flex: 2;
}
.cell {
display: flex;
align-items: center;
}
.cell:last-child {
justify-content: flex-end;
}
.actions {
display: flex;
gap: 12px;
}

View File

@ -33,7 +33,7 @@ export const ROLES = {
teamOwner: 'team-owner', teamOwner: 'team-owner',
teamMember: 'team-member', teamMember: 'team-member',
teamGuest: 'team-guest', teamGuest: 'team-guest',
}; } as const;
export const PERMISSIONS = { export const PERMISSIONS = {
all: 'all', all: 'all',

View File

@ -89,7 +89,7 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-basics": "^0.40.0", "react-basics": "^0.44.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-intl": "^5.24.7", "react-intl": "^5.24.7",

View File

@ -26,7 +26,7 @@ export default async (
const { id } = req.query; const { id } = req.query;
if (req.method === 'GET') { if (req.method === 'GET') {
if (await canViewUser(userId, id)) { if (!isAdmin && !(await canViewUser(userId, id))) {
return unauthorized(res); return unauthorized(res);
} }
@ -36,7 +36,7 @@ export default async (
} }
if (req.method === 'POST') { if (req.method === 'POST') {
if (await canUpdateUser(userId, id)) { if (!isAdmin && !(await canUpdateUser(userId, id))) {
return unauthorized(res); return unauthorized(res);
} }
@ -46,7 +46,8 @@ export default async (
const data: any = {}; const data: any = {};
if (password) { // Only admin can change these fields
if (password && isAdmin) {
data.password = hashPassword(password); data.password = hashPassword(password);
} }
@ -70,7 +71,7 @@ export default async (
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
if (isAdmin) { if (!isAdmin) {
return unauthorized(res); return unauthorized(res);
} }

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