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
113 changed files with 2054 additions and 1872 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import DateFilter from 'components/common/DateFilter';
import DropDown from 'components/common/DropDown';
import FormLayout, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { FixedSizeList } from 'react-window';
import firstBy from 'thenby';
import Icon from 'components/common/Icon';
import { Icon } from 'react-basics';
import Dot from 'components/common/Dot';
import FilterButtons from 'components/common/FilterButtons';
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 PageHeader from 'components/layout/PageHeader';
import WebsiteList from 'components/pages/WebsiteList';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import DashboardSettingsButton from 'components/settings/DashboardSettingsButton';
import useFetch from 'hooks/useFetch';
import useDashboard from 'store/dashboard';

View File

@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard';
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 { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import Page from 'components/layout/Page';
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';
import Layout from 'components/layout/Layout';
import Menu from 'components/nav/Nav';
import useRequireLogin from 'hooks/useRequireLogin';
import styles from './Settings.module.css';
const WEBSITES = '/settings';
const ACCOUNTS = '/settings/users';
const PROFILE = '/settings/profile';
export default function Settings({ children }) {
const { user: loggedIn } = useRequireLogin();
export default function Settings() {
const { user } = useUser();
const [option, setOption] = useState(WEBSITES);
const router = useRouter();
const { pathname } = router;
if (!user) {
if (!loggedIn) {
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 (
<Page>
<MenuLayout menu={menuOptions} selectedOption={option} onMenuSelect={setOption}>
{pathname === WEBSITES && <WebsiteSettings />}
{pathname === ACCOUNTS && <UserSettings />}
{pathname === PROFILE && <ProfileSettings />}
</MenuLayout>
</Page>
<Layout>
<div className={styles.dashboard}>
<div className={styles.nav}>
<Menu />
</div>
<div className={styles.content}>{children}</div>
</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 Link from 'next/link';
import { useRouter } from 'next/router';
import Page from 'components/layout/Page';
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 { Button, Column, Row } from 'react-basics';
import styles from './TestConsole.module.css';
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 (
<Page>
<EmptyPlaceholder msg={formatMessage(messages.noWebsites)}>
<Link href="/settings" icon={<Arrow />} iconRight>
<Link href="/websites" icon={<Arrow />} iconRight>
{formatMessage(messages.goToSettngs)}
</Link>
</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 Gear from 'assets/gear.svg';
import { saveDashboard } from 'store/dashboard';
import { Icon } from 'react-basics';
const messages = defineMessages({
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 { FormattedMessage } from 'react-intl';
import DateFilter, { filterOptions } from 'components/common/DateFilter';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import useDateRange from 'hooks/useDateRange';
import { DEFAULT_DATE_RANGE } from 'lib/constants';
import styles from './DateRangeSetting.module.css';
@@ -28,7 +28,7 @@ export default function DateRangeSetting() {
endDate={endDate}
onChange={handleChange}
/>
<Button className={styles.button} size="small" onClick={handleReset}>
<Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" />
</Button>
</>

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import DropDown from 'components/common/DropDown';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import useLocale from 'hooks/useLocale';
import { DEFAULT_LOCALE } from 'lib/constants';
import styles from './TimezoneSetting.module.css';
@@ -23,7 +23,7 @@ export default function LanguageSetting() {
options={options}
onChange={saveLocale}
/>
<Button className={styles.button} size="small" onClick={handleReset}>
<Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" />
</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 TimezoneSetting from './TimezoneSetting';
import DateRangeSetting from './DateRangeSetting';
import Button from 'components/common/Button';
import { Button, Icon } from 'react-basics';
import styles from './SettingsButton.module.css';
import Gear from 'assets/gear.svg';
import useDocumentClick from '../../hooks/useDocumentClick';
@@ -23,7 +23,11 @@ export default function SettingsButton() {
return (
<div className={styles.button} ref={ref}>
<Button icon={<Gear />} variant="light" onClick={handleClick} />
<Button variant="light" onClick={handleClick}>
<Icon>
<Gear />
</Icon>
</Button>
{show && (
<div className={styles.panel}>
<dt>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import { listTimeZones } from 'timezone-support';
import DropDown from 'components/common/DropDown';
import Button from 'components/common/Button';
import { Button } from 'react-basics';
import useTimezone from 'hooks/useTimezone';
import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css';
@@ -23,7 +23,7 @@ export default function TimezoneSetting() {
options={options}
onChange={saveTimezone}
/>
<Button className={styles.button} size="small" onClick={handleReset}>
<Button className={styles.button} size="sm" onClick={handleReset}>
<FormattedMessage id="label.reset" defaultMessage="Reset" />
</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 styles from './UserButton.module.css';
import { AUTH_TOKEN } from 'lib/constants';
import useUser from 'hooks/useUser';
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() {
const [show, setShow] = useState(false);
const ref = useRef();
const { user } = useUser();
const router = useRouter();
const { adminDisabled } = useConfig();
@@ -31,26 +33,48 @@ export default function UserButton() {
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
value: 'profile',
hidden: adminDisabled,
divider: true,
},
{ label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: 'logout' },
];
function handleClick() {
setShow(state => !state);
}
function handleSelect(value) {
if (value === 'logout') {
removeItem(AUTH_TOKEN);
router.push('/login');
} else if (value === 'profile') {
router.push('/settings/profile');
router.push('/profile');
}
}
useDocumentClick(e => {
if (!ref.current?.contains(e.target)) {
setShow(false);
}
});
return (
<MenuButton
icon={<Icon icon={<User />} size="large" />}
buttonVariant="light"
options={menuOptions}
onSelect={handleSelect}
hideLabel
/>
<div className={styles.button} ref={ref}>
<Button variant="light" onClick={handleClick}>
<Icon className={styles.icon} size="large">
<User />
</Icon>
</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 {
border-bottom: 1px solid var(--base500);
}
@@ -5,3 +9,18 @@
.username:hover {
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;
}