Initial commit

Created from https://vercel.com/new
This commit is contained in:
WaylonWalker 2021-12-08 18:25:42 +00:00
commit 4047a7ec23
378 changed files with 29334 additions and 0 deletions

View file

@ -0,0 +1,62 @@
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}
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

@ -0,0 +1,102 @@
.button {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-normal);
color: var(--gray900);
background: var(--gray100);
padding: 8px 16px;
border-radius: 4px;
border: 0;
outline: none;
cursor: pointer;
position: relative;
}
.button:hover {
background: var(--gray200);
}
.button:active {
color: var(--gray900);
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
}
.large {
font-size: var(--font-size-large);
}
.small {
font-size: var(--font-size-small);
}
.xsmall {
font-size: var(--font-size-xsmall);
}
.action,
.action:active {
color: var(--gray50);
background: var(--gray900);
}
.action:hover {
background: var(--gray800);
}
.danger,
.danger:active {
color: var(--gray50);
background: var(--red500);
}
.danger:hover {
background: var(--red400);
}
.light,
.light:active {
color: var(--gray900);
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(--gray500);
background: var(--gray75);
}
.button:disabled:active {
color: var(--gray500);
}
.button:disabled:hover {
background: var(--gray75);
}
.button.light:disabled {
background: var(--gray50);
}

View file

@ -0,0 +1,42 @@
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

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

View file

@ -0,0 +1,268 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import {
startOfWeek,
startOfMonth,
startOfYear,
endOfMonth,
addDays,
subDays,
addYears,
subYears,
addMonths,
setMonth,
setYear,
isSameDay,
isBefore,
isAfter,
} from 'date-fns';
import Button from './Button';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date';
import { chunk } from 'lib/array';
import { getDateLocale } from 'lib/lang';
import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg';
import styles from './Calendar.module.css';
import Icon from './Icon';
export default function Calendar({ date, minDate, maxDate, onChange }) {
const { locale } = useLocale();
const [selectMonth, setSelectMonth] = useState(false);
const [selectYear, setSelectYear] = useState(false);
const month = dateFormat(date, 'MMMM', locale);
const year = date.getFullYear();
function toggleMonthSelect() {
setSelectYear(false);
setSelectMonth(state => !state);
}
function toggleYearSelect() {
setSelectMonth(false);
setSelectYear(state => !state);
}
function handleChange(value) {
setSelectMonth(false);
setSelectYear(false);
if (value) {
onChange(value);
}
}
return (
<div className={styles.calendar}>
<div className={styles.header}>
<div>{date.getDate()}</div>
<div
className={classNames(styles.selector, { [styles.open]: selectMonth })}
onClick={toggleMonthSelect}
>
{month}
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
</div>
<div
className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect}
>
{year}
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
</div>
</div>
<div className={styles.body}>
{!selectMonth && !selectYear && (
<DaySelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
/>
)}
{selectMonth && (
<MonthSelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
onClose={toggleMonthSelect}
/>
)}
{selectYear && (
<YearSelector
date={date}
minDate={minDate}
maxDate={maxDate}
onSelect={handleChange}
onClose={toggleYearSelect}
/>
)}
</div>
</div>
);
}
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const startWeek = startOfWeek(date, { locale: getDateLocale(locale) });
const startMonth = startOfMonth(date, { locale: getDateLocale(locale) });
const startDay = subDays(startMonth, startMonth.getDay());
const month = date.getMonth();
const year = date.getFullYear();
const daysOfWeek = [];
for (let i = 0; i < 7; i++) {
daysOfWeek.push(addDays(startWeek, i));
}
const days = [];
for (let i = 0; i < 35; i++) {
days.push(addDays(startDay, i));
}
return (
<table>
<thead>
<tr>
{daysOfWeek.map((day, i) => (
<th key={i} className={locale}>
{dateFormat(day, 'EEE', locale)}
</th>
))}
</tr>
</thead>
<tbody>
{chunk(days, 7).map((week, i) => (
<tr key={i}>
{week.map((day, j) => {
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
return (
<td
key={j}
className={classNames({
[styles.selected]: isSameDay(date, day),
[styles.faded]: day.getMonth() !== month || day.getFullYear() !== year,
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => onSelect(day) : null}
>
{day.getDate()}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const start = startOfYear(date);
const months = [];
for (let i = 0; i < 12; i++) {
months.push(addMonths(start, i));
}
function handleSelect(value) {
onSelect(setMonth(date, value));
}
return (
<table>
<tbody>
{chunk(months, 3).map((row, i) => (
<tr key={i}>
{row.map((month, j) => {
const disabled =
isBefore(endOfMonth(month), minDate) || isAfter(startOfMonth(month), maxDate);
return (
<td
key={j}
className={classNames(locale, {
[styles.selected]: month.getMonth() === date.getMonth(),
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => handleSelect(month.getMonth()) : null}
>
{dateFormat(month, 'MMMM', locale)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
const [currentDate, setCurrentDate] = useState(date);
const year = date.getFullYear();
const currentYear = currentDate.getFullYear();
const minYear = minDate.getFullYear();
const maxYear = maxDate.getFullYear();
const years = [];
for (let i = 0; i < 15; i++) {
years.push(currentYear - 7 + i);
}
function handleSelect(value) {
onSelect(setYear(date, value));
}
function handlePrevClick() {
setCurrentDate(state => subYears(state, 15));
}
function handleNextClick() {
setCurrentDate(state => addYears(state, 15));
}
return (
<div className={styles.pager}>
<div className={styles.left}>
<Button
icon={<Chevron />}
size="small"
onClick={handlePrevClick}
disabled={years[0] <= minYear}
variant="light"
/>
</div>
<div className={styles.middle}>
<table>
<tbody>
{chunk(years, 5).map((row, i) => (
<tr key={i}>
{row.map((n, j) => (
<td
key={j}
className={classNames({
[styles.selected]: n === year,
[styles.disabled]: n < minYear || n > maxYear,
})}
onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))}
>
{n}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.right}>
<Button
icon={<Chevron />}
size="small"
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
variant="light"
/>
</div>
</div>
);
};

View file

@ -0,0 +1,111 @@
.calendar {
display: flex;
flex-direction: column;
font-size: var(--font-size-small);
flex: 1;
min-height: 306px;
}
.calendar table {
width: 100%;
border-spacing: 5px;
}
.calendar td {
color: var(--gray800);
cursor: pointer;
text-align: center;
vertical-align: center;
height: 40px;
width: 40px;
border-radius: 5px;
border: 1px solid transparent;
}
.calendar td:hover {
border: 1px solid var(--gray300);
background: var(--gray75);
}
.calendar td.faded {
color: var(--gray500);
}
.calendar td.selected {
font-weight: 600;
border: 1px solid var(--gray600);
}
.calendar td.selected:hover {
background: transparent;
}
.calendar td.disabled {
color: var(--gray400);
background: var(--gray75);
}
.calendar td.disabled:hover {
cursor: default;
background: var(--gray75);
border-color: transparent;
}
.calendar td.faded.disabled {
background: var(--gray100);
}
.header {
display: flex;
justify-content: space-evenly;
align-items: center;
font-weight: 700;
line-height: 40px;
font-size: var(--font-size-normal);
}
.body {
display: flex;
}
.selector {
cursor: pointer;
}
.pager {
display: flex;
flex: 1;
}
.pager button {
align-self: center;
}
.middle {
flex: 1;
}
.left,
.right {
display: flex;
justify-content: center;
align-items: center;
}
.left svg {
transform: rotate(90deg);
}
.right svg {
transform: rotate(-90deg);
}
.icon {
margin-left: 10px;
}
@media only screen and (max-width: 992px) {
.calendar table {
max-width: calc(100vw - 30px);
}
}

View file

@ -0,0 +1,39 @@
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

@ -0,0 +1,30 @@
.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(--gray500);
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

@ -0,0 +1,37 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Button from './Button';
import { FormattedMessage } from 'react-intl';
const defaultText = (
<FormattedMessage id="label.copy-to-clipboard" defaultMessage="Copy to clipboard" />
);
function CopyButton({ element, ...props }) {
const [text, setText] = useState(defaultText);
function handleClick() {
if (element?.current) {
element.current.select();
document.execCommand('copy');
setText(<FormattedMessage id="message.copied" defaultMessage="Copied!" />);
window.getSelection().removeAllRanges();
}
}
return (
<Button {...props} onClick={handleClick}>
{text}
</Button>
);
}
CopyButton.propTypes = {
element: PropTypes.shape({
current: PropTypes.shape({
select: PropTypes.func.isRequired,
}),
}),
};
export default CopyButton;

View file

@ -0,0 +1,130 @@
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 DatePickerForm from 'components/forms/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { getDateRange, dateFormat } from 'lib/date';
import Calendar from 'assets/calendar-alt.svg';
import Icon from './Icon';
const filterOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
{
label: (
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
),
value: '24hour',
},
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
),
value: '7day',
},
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
value: '1month',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
),
value: '30day',
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 90 }} />
),
value: '90day',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
value: 'custom',
divider: true,
},
];
function DateFilter({ value, startDate, endDate, onChange, className }) {
const { locale } = useLocale();
const [showPicker, setShowPicker] = useState(false);
const displayValue =
value === 'custom' ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
value
);
function handleChange(value) {
if (value === 'custom') {
setShowPicker(true);
return;
}
onChange(getDateRange(value, locale));
}
function handlePickerChange(value) {
setShowPicker(false);
onChange(value);
}
return (
<>
<DropDown
className={className}
value={displayValue}
options={filterOptions}
onChange={handleChange}
/>
{showPicker && (
<Modal>
<DatePickerForm
startDate={startDate}
endDate={endDate}
minDate={new Date(2000, 0, 1)}
maxDate={endOfYear(new Date())}
onChange={handlePickerChange}
onClose={() => setShowPicker(false)}
/>
</Modal>
)}
</>
);
}
const CustomRange = ({ startDate, endDate, onClick }) => {
const { locale } = useLocale();
function handleClick(e) {
e.stopPropagation();
onClick();
}
return (
<>
<Icon icon={<Calendar />} className="mr-2" onClick={handleClick} />
{dateFormat(startDate, 'd LLL y', locale)}
{!isSameDay(startDate, endDate) && `${dateFormat(endDate, 'd LLL y', locale)}`}
</>
);
};
DateFilter.propTypes = {
value: PropTypes.string,
startDate: PropTypes.instanceOf(Date),
endDate: PropTypes.instanceOf(Date),
onChange: PropTypes.func,
className: PropTypes.string,
};
export default DateFilter;

26
components/common/Dot.js Normal file
View file

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

View file

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

View file

@ -0,0 +1,64 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Menu from './Menu';
import useDocumentClick from 'hooks/useDocumentClick';
import Chevron from 'assets/chevron-down.svg';
import styles from './Dropdown.module.css';
import Icon from './Icon';
function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleShowMenu() {
setShowMenu(state => !state);
}
function handleSelect(selected, e) {
e.stopPropagation();
setShowMenu(false);
onChange(selected);
}
useDocumentClick(e => {
if (!ref.current?.contains(e.target)) {
setShowMenu(false);
}
});
return (
<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" />
</div>
{showMenu && (
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float="bottom"
/>
)}
</div>
);
}
DropDown.propTypes = {
value: PropTypes.any,
className: PropTypes.string,
menuClassName: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.any.isRequired,
label: PropTypes.node,
}),
),
onChange: PropTypes.func,
};
export default DropDown;

View file

@ -0,0 +1,28 @@
.dropdown {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--gray500);
border-radius: 4px;
cursor: pointer;
}
.value {
flex: 1;
display: flex;
justify-content: space-between;
font-size: var(--font-size-small);
flex-wrap: nowrap;
white-space: nowrap;
padding: 4px 16px;
min-width: 160px;
}
.text {
flex: 1;
}
.icon {
padding-left: 20px;
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'components/common/Icon';
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" />
<h2 className={styles.msg}>{msg}</h2>
{children}
</div>
);
}
EmptyPlaceholder.propTypes = {
msg: PropTypes.node,
children: PropTypes.node,
};
export default EmptyPlaceholder;

View file

@ -0,0 +1,15 @@
.placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 600px;
}
.icon {
margin-bottom: 30px;
}
.msg {
margin-bottom: 15px;
}

View file

@ -0,0 +1,14 @@
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';
export default function ErrorMessage() {
return (
<div className={styles.error}>
<Icon icon={<Exclamation />} className={styles.icon} size="large" />
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
</div>
);
}

View file

@ -0,0 +1,15 @@
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: auto;
display: flex;
z-index: 1;
background-color: var(--gray50);
padding: 10px;
}
.icon {
margin-right: 10px;
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Favicon.module.css';
function getHostName(url) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}
function Favicon({ domain, ...props }) {
const hostName = domain ? getHostName(domain) : null;
return hostName ? (
<img
className={styles.favicon}
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
height="16"
alt=""
{...props}
/>
) : null;
}
Favicon.propTypes = {
domain: PropTypes.string,
};
export default Favicon;

View file

@ -0,0 +1,3 @@
.favicon {
margin-right: 8px;
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import ButtonLayout from 'components/layout/ButtonLayout';
import ButtonGroup from './ButtonGroup';
function FilterButtons({ buttons, selected, onClick }) {
return (
<ButtonLayout>
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
</ButtonLayout>
);
}
FilterButtons.propTypes = {
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any.isRequired,
}),
),
selected: PropTypes.any,
onClick: PropTypes.func,
};
export default FilterButtons;

29
components/common/Icon.js Normal file
View file

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

View file

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

34
components/common/Link.js Normal file
View file

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

View file

@ -0,0 +1,50 @@
a.link,
a.link:active,
a.link:visited {
position: relative;
color: var(--gray900);
text-decoration: none;
display: inline-flex;
align-items: center;
}
a.link:before {
content: '';
position: absolute;
bottom: -2px;
width: 0;
height: 2px;
background: var(--primary400);
opacity: 0.5;
transition: width 100ms;
}
a.link:hover:before {
width: 100%;
transition: width 100ms;
}
a.link.large {
font-size: var(--font-size-large);
}
a.link.small {
font-size: var(--font-size-small);
}
a.link.xsmall {
font-size: var(--font-size-xsmall);
}
a.link .icon + * {
margin-left: 10px;
}
a.link.iconRight .icon {
order: 1;
margin-left: 10px;
}
a.link.iconRight .icon + * {
margin: 0;
}

View file

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

View file

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

70
components/common/Menu.js Normal file
View file

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

View file

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

View file

@ -0,0 +1,86 @@
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';
function MenuButton({
icon,
value,
options,
buttonClassName,
menuClassName,
menuPosition = 'bottom',
menuAlign = 'right',
onSelect,
renderValue,
hideLabel,
}) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleSelect(value) {
onSelect(value);
setShowMenu(false);
}
function toggleMenu() {
setShowMenu(state => !state);
}
useDocumentClick(e => {
if (!ref.current?.contains(e.target)) {
setShowMenu(false);
}
});
return (
<div className={styles.container} ref={ref}>
<Button
icon={icon}
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
onClick={toggleMenu}
variant="light"
>
{!hideLabel && (
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
)}
</Button>
{showMenu && (
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float={menuPosition}
align={menuAlign}
/>
)}
</div>
);
}
MenuButton.propTypes = {
icon: PropTypes.node,
value: PropTypes.any,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
className: PropTypes.string,
render: PropTypes.func,
divider: PropTypes.bool,
}),
),
buttonClassName: PropTypes.string,
menuClassName: PropTypes.string,
menuPosition: PropTypes.oneOf(['top', 'bottom']),
menuAlign: PropTypes.oneOf(['left', 'right']),
onSelect: PropTypes.func,
renderValue: PropTypes.func,
};
export default MenuButton;

View file

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

View file

@ -0,0 +1,26 @@
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

@ -0,0 +1,46 @@
.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(--gray50);
min-width: 400px;
min-height: 100px;
max-width: 100vw;
z-index: 1;
border: 1px solid var(--gray300);
padding: 30px;
border-radius: 4px;
}
.header {
font-weight: 600;
margin-bottom: 20px;
}
.body {
display: flex;
flex-direction: column;
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import styles from './NoData.module.css';
function NoData({ className }) {
return (
<div className={classNames(styles.container, className)}>
<FormattedMessage id="message.no-data-available" defaultMessage="No data available." />
</div>
);
}
NoData.propTypes = {
className: PropTypes.string,
};
export default NoData;

View file

@ -0,0 +1,11 @@
.container {
color: var(--gray500);
font-size: var(--font-size-normal);
position: relative;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { setDateRange } from 'redux/actions/websites';
import Button from './Button';
import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg';
import useDateRange from 'hooks/useDateRange';
import { getDateRange } from '../../lib/date';
import useLocale from 'hooks/useLocale';
function RefreshButton({ websiteId }) {
const dispatch = useDispatch();
const { locale } = useLocale();
const [dateRange] = useDateRange(websiteId);
const [loading, setLoading] = useState(false);
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]);
function handleClick() {
if (dateRange) {
setLoading(true);
dispatch(setDateRange(websiteId, getDateRange(dateRange.value, locale)));
}
}
useEffect(() => {
setLoading(false);
}, [completed]);
return (
<Button
icon={loading ? <Dots /> : <Refresh />}
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
tooltipId="button-refresh"
size="small"
onClick={handleClick}
/>
);
}
RefreshButton.propTypes = {
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default RefreshButton;

View file

@ -0,0 +1,89 @@
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, render, className, style, cell }, index) => (
<div
key={`${key}-${index}`}
className={classNames(styles.cell, className, cell?.className)}
style={{ ...style, ...cell?.style }}
>
{render ? render(row) : row[key]}
</div>
))}
</div>
);

View file

@ -0,0 +1,30 @@
.table {
display: flex;
flex-direction: column;
}
.header {
border-bottom: 1px solid var(--gray300);
}
.head {
font-size: var(--font-size-small);
font-weight: 600;
line-height: 40px;
}
.body {
position: relative;
display: flex;
flex-direction: column;
}
.row {
border-bottom: 1px solid var(--gray300);
padding: 10px 0;
}
.cell {
display: flex;
align-items: flex-start;
}

15
components/common/Tag.js Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Tag.module.css';
function Tag({ className, children }) {
return <span className={classNames(styles.tag, className)}>{children}</span>;
}
Tag.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default Tag;

View file

@ -0,0 +1,6 @@
.tag {
padding: 2px 4px;
border: 1px solid var(--gray300);
border-radius: 4px;
margin-right: 10px;
}

View file

@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import styles from './Toast.module.css';
import Icon from 'components/common/Icon';
import Close from 'assets/times.svg';
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

@ -0,0 +1,25 @@
.toast {
position: absolute;
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-normal);
}
.close {
margin-left: 20px;
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import useVersion from 'hooks/useVersion';
import styles from './UpdateNotice.module.css';
import ButtonLayout from '../layout/ButtonLayout';
import Button from './Button';
import useForceUpdate from '../../hooks/useForceUpdate';
export default function UpdateNotice() {
const forceUpdate = useForceUpdate();
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
function handleViewClick() {
location.href = 'https://github.com/mikecao/umami/releases';
updateCheck();
forceUpdate();
}
function handleDismissClick() {
updateCheck();
forceUpdate();
}
if (!hasUpdate || checked) {
return null;
}
return (
<div className={styles.notice}>
<div className={styles.message}>
<FormattedMessage
id="message.new-version-available"
defaultMessage="A new version of umami {version} is available!"
values={{ version: `v${latest}` }}
/>
</div>
<ButtonLayout>
<Button size="xsmall" variant="action" onClick={handleViewClick}>
<FormattedMessage id="label.view-details" defaultMessage="View details" />
</Button>
<Button size="xsmall" onClick={handleDismissClick}>
<FormattedMessage id="label.dismiss" defaultMessage="Dismiss" />
</Button>
</ButtonLayout>
</div>
);
}

View file

@ -0,0 +1,13 @@
.notice {
display: flex;
justify-content: center;
align-items: center;
padding-top: 10px;
font-size: var(--font-size-small);
font-weight: 600;
}
.message {
text-align: center;
margin-right: 20px;
}

View file

@ -0,0 +1,103 @@
import React, { useState, useMemo } from 'react';
import { useRouter } from 'next/router';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import classNames from 'classnames';
import tinycolor from 'tinycolor2';
import useTheme from 'hooks/useTheme';
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants';
import styles from './WorldMap.module.css';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
function WorldMap({ data, className }) {
const { basePath } = useRouter();
const [tooltip, setTooltip] = useState();
const [theme] = useTheme();
const colors = useMemo(
() => ({
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
}),
[theme],
);
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
function getFillColor(code) {
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
if (!country) {
return colors.fillColor;
}
return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken'](
40 * (1.0 - country.z / 100),
);
}
function getOpacity(code) {
return code === 'AQ' ? 0 : 1;
}
function handleHover(code) {
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
}
return (
<div
className={classNames(styles.container, className)}
data-tip=""
data-for="world-map-tooltip"
>
<ComposableMap projection="geoMercator">
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
<Geographies geography={`${basePath}${MAP_FILE}`}>
{({ geographies }) => {
return geographies.map(geo => {
const code = ISO_COUNTRIES[geo.id];
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={colors.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: colors.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(code)}
onMouseOut={() => setTooltip(null)}
/>
);
});
}}
</Geographies>
</ZoomableGroup>
</ComposableMap>
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
</div>
);
}
WorldMap.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
x: PropTypes.string,
y: PropTypes.number,
z: PropTypes.number,
}),
),
className: PropTypes.string,
};
export default WorldMap;

View file

@ -0,0 +1,4 @@
.container {
overflow: hidden;
position: relative;
}