Merge pull request #154 from mikecao/dev

v0.32.0
This commit is contained in:
Mike Cao 2020-09-13 20:16:18 -07:00 committed by GitHub
commit d6877af74c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 884 additions and 210 deletions

View File

@ -92,7 +92,6 @@ Or with MySQL support:
docker pull ghcr.io/mikecao/umami:mysql-latest docker pull ghcr.io/mikecao/umami:mysql-latest
``` ```
## Getting updates ## Getting updates
To get the latest features, simply do a pull, install any new dependencies, and rebuild: To get the latest features, simply do a pull, install any new dependencies, and rebuild:

1
assets/calendar-alt.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16zm352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16zM148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12z"/></svg>

After

Width:  |  Height:  |  Size: 1002 B

1
assets/list-ul.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M48 368a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 24H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V88a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@ -28,6 +28,7 @@ export default function WebsiteDetails({ websiteId }) {
const BackButton = () => ( const BackButton = () => (
<Button <Button
key="back-button"
className={styles.backButton} className={styles.backButton}
icon={<Arrow />} icon={<Arrow />}
size="xsmall" size="xsmall"

View File

@ -9,9 +9,9 @@ import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg'; import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteList.module.css'; import styles from './WebsiteList.module.css';
export default function WebsiteList() { export default function WebsiteList({ userId }) {
const router = useRouter(); const router = useRouter();
const { data } = useFetch('/api/websites'); const { data } = useFetch('/api/websites', { userId });
if (!data) { if (!data) {
return null; return null;

View File

@ -13,6 +13,8 @@ export default function Button({
className, className,
tooltip, tooltip,
tooltipId, tooltipId,
disabled = false,
onClick = () => {},
...props ...props
}) { }) {
return ( return (
@ -27,7 +29,11 @@ export default function Button({
[styles.xsmall]: size === 'xsmall', [styles.xsmall]: size === 'xsmall',
[styles.action]: variant === 'action', [styles.action]: variant === 'action',
[styles.danger]: variant === 'danger', [styles.danger]: variant === 'danger',
[styles.light]: variant === 'light',
[styles.disabled]: disabled,
})} })}
disabled={disabled}
onClick={!disabled ? onClick : null}
{...props} {...props}
> >
{icon && <Icon icon={icon} size={size} />} {icon && <Icon icon={icon} size={size} />}

View File

@ -14,7 +14,7 @@
} }
.button:hover { .button:hover {
background: #eaeaea; background: var(--gray200);
} }
.button:active { .button:active {
@ -38,19 +38,45 @@
} }
.action { .action {
color: var(--gray50) !important; color: var(--gray50);
background: var(--gray900) !important; background: var(--gray900);
} }
.action:hover { .action:hover {
background: var(--gray800) !important; background: var(--gray800);
} }
.danger { .danger {
color: var(--gray50) !important; color: var(--gray50);
background: var(--red500) !important; background: var(--red500);
} }
.danger:hover { .danger:hover {
background: var(--red400) !important; background: var(--red400);
}
.light {
background: var(--gray50);
}
.light:hover {
background: var(--gray75);
}
.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,261 @@
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/lang';
import { chunk } from 'lib/array';
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>
{!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>
);
}
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const startWeek = startOfWeek(date);
const startMonth = startOfMonth(date);
const startDay = subDays(startMonth, startMonth.getDay() + 1);
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}>
<Button
icon={<Chevron />}
size="small"
className={styles.left}
onClick={handlePrevClick}
disabled={years[0] <= minYear}
variant="light"
/>
<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>
<Button
icon={<Chevron />}
size="small"
className={styles.right}
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
variant="light"
/>
</div>
);
};

View File

@ -0,0 +1,85 @@
.calendar {
display: flex;
flex-direction: column;
font-size: var(--font-size-small);
flex: 1;
min-height: 285px;
}
.calendar table {
flex: 1;
}
.calendar td {
color: var(--gray800);
cursor: pointer;
text-align: center;
vertical-align: center;
height: 40px;
min-width: 40px;
border-radius: 5px;
}
.calendar td:hover {
background: var(--gray100);
}
.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);
}
.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);
}
.selector {
cursor: pointer;
}
.pager {
display: flex;
}
.pager button {
align-self: center;
}
.left svg {
transform: rotate(90deg);
}
.right svg {
transform: rotate(-90deg);
}
.icon {
margin-left: 10px;
}

View File

@ -1,21 +1,39 @@
import React from 'react'; import React, { useState } from 'react';
import { getDateRange } from 'lib/date';
import DropDown from './DropDown';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { endOfYear } from 'date-fns';
import Modal from './Modal';
import DropDown from './DropDown';
import DatePickerForm from 'components/forms/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { getDateRange } from 'lib/date';
import { dateFormat } from 'lib/lang';
import Calendar from 'assets/calendar-alt.svg';
import Icon from './Icon';
const filterOptions = [ const filterOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
{ {
label: ( label: (
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} /> <FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
), ),
value: '24hour', value: '24hour',
}, },
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week',
divider: true,
},
{ {
label: ( label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} /> <FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
), ),
value: '7day', value: '7day',
}, },
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
value: '1month',
divider: true,
},
{ {
label: ( label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} /> <FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
@ -28,21 +46,73 @@ const filterOptions = [
), ),
value: '90day', value: '90day',
}, },
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
{ label: <FormattedMessage id="label.this-week" defaultMessage="This week" />, value: '1week' },
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
value: '1month',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' }, { label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
value: 'custom',
divider: true,
},
]; ];
export default function DateFilter({ value, onChange, className }) { export default function DateFilter({ value, startDate, endDate, onChange, className }) {
const [showPicker, setShowPicker] = useState(false);
const displayValue =
value === 'custom' ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
value
);
function handleChange(value) { function handleChange(value) {
if (value === 'custom') {
setShowPicker(true);
return;
}
onChange(getDateRange(value)); onChange(getDateRange(value));
} }
function handlePickerChange(value) {
setShowPicker(false);
onChange(value);
}
return ( return (
<DropDown className={className} value={value} options={filterOptions} onChange={handleChange} /> <>
<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)}${dateFormat(endDate, 'd LLL y', locale)}`}
</>
);
};

View File

@ -23,9 +23,8 @@ export default function DropDown({
function handleSelect(selected, e) { function handleSelect(selected, e) {
e.stopPropagation(); e.stopPropagation();
setShowMenu(false); setShowMenu(false);
if (selected !== value) {
onChange(selected); onChange(selected);
}
} }
useDocumentClick(e => { useDocumentClick(e => {
@ -37,8 +36,8 @@ export default function DropDown({
return ( return (
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}> <div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}> <div className={styles.value}>
{options.find(e => e.value === value)?.label} {options.find(e => e.value === value)?.label || value}
<Icon icon={<Chevron />} size="small" /> <Icon icon={<Chevron />} className={styles.icon} size="small" />
</div> </div>
{showMenu && ( {showMenu && (
<Menu className={menuClassName} options={options} onSelect={handleSelect} float="bottom" /> <Menu className={menuClassName} options={options} onSelect={handleSelect} float="bottom" />

View File

@ -1,16 +1,24 @@
.dropdown { .dropdown {
position: relative; position: relative;
font-size: var(--font-size-small);
min-width: 140px;
}
.value {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
white-space: nowrap; align-items: center;
position: relative;
padding: 4px 16px;
border: 1px solid var(--gray500); border: 1px solid var(--gray500);
border-radius: 4px; border-radius: 4px;
cursor: pointer; 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;
}
.icon {
padding-left: 10px;
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import styles from './Icon.module.css'; import styles from './Icon.module.css';
export default function Icon({ icon, className, size = 'medium' }) { export default function Icon({ icon, className, size = 'medium', ...props }) {
return ( return (
<div <div
className={classNames(styles.icon, className, { className={classNames(styles.icon, className, {
@ -12,6 +12,7 @@ export default function Icon({ icon, className, size = 'medium' }) {
[styles.small]: size === 'small', [styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall', [styles.xsmall]: size === 'xsmall',
})} })}
{...props}
> >
{icon} {icon}
</div> </div>

View File

@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import Head from 'next/head';
import Globe from 'assets/globe.svg'; import Globe from 'assets/globe.svg';
import useDocumentClick from 'hooks/useDocumentClick'; import useDocumentClick from 'hooks/useDocumentClick';
import Menu from './Menu'; import Menu from './Menu';
@ -19,6 +20,10 @@ export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'l
setShowMenu(false); setShowMenu(false);
} }
function toggleMenu() {
setShowMenu(state => !state);
}
useDocumentClick(e => { useDocumentClick(e => {
if (!ref.current.contains(e.target)) { if (!ref.current.contains(e.target)) {
setShowMenu(false); setShowMenu(false);
@ -26,19 +31,35 @@ export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'l
}); });
return ( return (
<div ref={ref} className={styles.container}> <>
<Button icon={<Globe />} onClick={() => setShowMenu(true)} size="small"> <Head>
<div className={locale}>{selectedLocale}</div> {locale === 'zh-CN' && (
</Button> <link
{showMenu && ( href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap"
<Menu rel="stylesheet"
className={styles.menu} />
options={menuOptions} )}
onSelect={handleSelect} {locale === 'ja-JP' && (
float={menuPosition} <link
align={menuAlign} href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap"
/> rel="stylesheet"
)} />
</div> )}
</Head>
<div ref={ref} className={styles.container}>
<Button icon={<Globe />} onClick={toggleMenu} size="small">
<div className={locale}>{selectedLocale}</div>
</Button>
{showMenu && (
<Menu
className={styles.menu}
options={menuOptions}
onSelect={handleSelect}
float={menuPosition}
align={menuAlign}
/>
)}
</div>
</>
); );
} }

View File

@ -25,7 +25,7 @@ export default function Menu({
{options {options
.filter(({ hidden }) => !hidden) .filter(({ hidden }) => !hidden)
.map(option => { .map(option => {
const { label, value, className: customClassName, render } = option; const { label, value, className: customClassName, render, divider } = option;
return render ? ( return render ? (
render(option) render(option)
@ -34,6 +34,7 @@ export default function Menu({
key={value} key={value}
className={classNames(styles.option, optionClassName, customClassName, { className={classNames(styles.option, optionClassName, customClassName, {
[selectedClassName]: selectedOption === value, [selectedClassName]: selectedOption === value,
[styles.divider]: divider,
})} })}
onClick={e => onSelect(value, e)} onClick={e => onSelect(value, e)}
> >

View File

@ -40,3 +40,7 @@
.right { .right {
right: 0; right: 0;
} }
.divider {
border-top: 1px solid var(--gray300);
}

View File

@ -1,5 +1,5 @@
.modal { .modal {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -28,6 +28,7 @@
background: var(--gray50); background: var(--gray50);
min-width: 400px; min-width: 400px;
min-height: 100px; min-height: 100px;
max-width: 100vw;
z-index: 1; z-index: 1;
border: 1px solid var(--gray300); border: 1px solid var(--gray300);
padding: 30px; padding: 30px;

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { isAfter } from 'date-fns';
import Calendar from 'components/common/Calendar';
import Button from 'components/common/Button';
import { FormButtons } from 'components/layout/FormLayout';
import { getDateRangeValues } from 'lib/date';
import styles from './DatePickerForm.module.css';
export default function DatePickerForm({
startDate: defaultStartDate,
endDate: defaultEndDate,
minDate,
maxDate,
onChange,
onClose,
}) {
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
function handleSave() {
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
}
return (
<div className={styles.container}>
<div className={styles.calendars}>
<Calendar date={startDate} minDate={minDate} maxDate={endDate} onChange={setStartDate} />
<Calendar date={endDate} minDate={startDate} maxDate={maxDate} onChange={setEndDate} />
</div>
<FormButtons>
<Button variant="action" onClick={handleSave} disabled={isAfter(startDate, endDate)}>
<FormattedMessage id="button.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</div>
);
}

View File

@ -0,0 +1,25 @@
.container {
display: flex;
flex-direction: column;
width: 800px;
max-width: 100vw;
}
.calendars {
display: flex;
}
.calendars > div:first-child {
padding-right: 20px;
border-right: 1px solid var(--gray300);
}
.calendars > div:last-child {
padding-left: 20px;
}
@media only screen and (max-width: 768px) {
.calendars {
flex-direction: column;
}
}

View File

@ -10,10 +10,12 @@ import FormLayout, {
} from 'components/layout/FormLayout'; } from 'components/layout/FormLayout';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const CONFIRMATION_WORD = 'DELETE';
const validate = ({ confirmation }) => { const validate = ({ confirmation }) => {
const errors = {}; const errors = {};
if (confirmation !== 'DELETE') { if (confirmation !== CONFIRMATION_WORD) {
errors.confirmation = !confirmation ? ( errors.confirmation = !confirmation ? (
<FormattedMessage id="label.required" defaultMessage="Required" /> <FormattedMessage id="label.required" defaultMessage="Required" />
) : ( ) : (
@ -44,7 +46,7 @@ export default function DeleteForm({ values, onSave, onClose }) {
validate={validate} validate={validate}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{() => ( {props => (
<Form> <Form>
<div> <div>
<FormattedMessage <FormattedMessage
@ -63,7 +65,7 @@ export default function DeleteForm({ values, onSave, onClose }) {
<FormattedMessage <FormattedMessage
id="message.type-delete" id="message.type-delete"
defaultMessage="Type {delete} in the box below to confirm." defaultMessage="Type {delete} in the box below to confirm."
values={{ delete: <b>DELETE</b> }} values={{ delete: <b>{CONFIRMATION_WORD}</b> }}
/> />
</p> </p>
<FormRow> <FormRow>
@ -71,7 +73,11 @@ export default function DeleteForm({ values, onSave, onClose }) {
<FormError name="confirmation" /> <FormError name="confirmation" />
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<Button type="submit" variant="danger"> <Button
type="submit"
variant="danger"
disabled={props.values.confirmation !== CONFIRMATION_WORD}
>
<FormattedMessage id="button.delete" defaultMessage="Delete" /> <FormattedMessage id="button.delete" defaultMessage="Delete" />
</Button> </Button>
<Button onClick={onClose}> <Button onClick={onClose}>

View File

@ -13,10 +13,6 @@ export default function Layout({ title, children, header = true, footer = true }
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap"
rel="stylesheet"
/>
</Head> </Head>
{header && <Header />} {header && <Header />}
<main className="container">{children}</main> <main className="container">{children}</main>

View File

@ -44,9 +44,9 @@ export default function BarChart({
return dateFormat(d, 'EEE M/d', locale); return dateFormat(d, 'EEE M/d', locale);
case 'month': case 'month':
if (w <= 660) { if (w <= 660) {
return dateFormat(d, 'MMM', locale); return index % 2 === 0 ? dateFormat(d, 'MMM', locale) : '';
} }
return dateFormat(d, 'MMMM', locale); return dateFormat(d, 'MMM', locale);
default: default:
return label; return label;
} }
@ -64,14 +64,8 @@ export default function BarChart({
} else { } else {
const [label, value] = body[0].lines[0].split(':'); const [label, value] = body[0].lines[0].split(':');
console.log(
+title[0],
new Date(+title[0]),
dateFormat(new Date(+title[0]), 'EEE MMMM d yyyy', locale),
);
setTooltip({ setTooltip({
title: dateFormat(new Date(+title[0]), 'EEE MMMM d yyyy', locale), title: dateFormat(new Date(+title[0]), getTooltipFormat(unit), locale),
value, value,
label, label,
labelColor: labelColors[0].backgroundColor, labelColor: labelColors[0].backgroundColor,
@ -79,6 +73,15 @@ export default function BarChart({
} }
} }
function getTooltipFormat(unit) {
switch (unit) {
case 'hour':
return 'EEE ha — MMM d yyyy';
default:
return 'EEE MMMM d yyyy';
}
}
function createChart() { function createChart() {
const options = { const options = {
animation: { animation: {

View File

@ -2,8 +2,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-width: 120px; min-width: 140px;
margin-right: 20px; padding-right: 20px;
} }
.value { .value {
@ -16,4 +16,5 @@
.label { .label {
font-size: var(--font-size-normal); font-size: var(--font-size-normal);
white-space: nowrap;
} }

View File

@ -4,6 +4,9 @@
} }
@media only screen and (max-width: 992px) { @media only screen and (max-width: 992px) {
.bar {
justify-content: space-between;
}
.bar > div:last-child { .bar > div:last-child {
display: none; display: none;
} }

View File

@ -3,7 +3,6 @@ import { useDispatch } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import PageviewsChart from './PageviewsChart'; import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar'; import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons';
import DateFilter from 'components/common/DateFilter'; import DateFilter from 'components/common/DateFilter';
import StickyHeader from 'components/helpers/StickyHeader'; import StickyHeader from 'components/helpers/StickyHeader';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
@ -58,12 +57,17 @@ export default function WebsiteChart({
stickyClassName={styles.sticky} stickyClassName={styles.sticky}
enabled={stickyHeader} enabled={stickyHeader}
> >
<MetricsBar className="col-12 col-md-9 col-lg-10" websiteId={websiteId} /> <div className="col-12 col-lg-9">
<DateFilter <MetricsBar websiteId={websiteId} />
className="col-12 col-md-3 col-lg-2" </div>
value={value} <div className={classNames(styles.filter, 'col-12 col-lg-3')}>
onChange={handleDateChange} <DateFilter
/> value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
/>
</div>
</StickyHeader> </StickyHeader>
</div> </div>
<div className="row"> <div className="row">
@ -74,7 +78,6 @@ export default function WebsiteChart({
unit={unit} unit={unit}
records={getDateLength(startDate, endDate, unit)} records={getDateLength(startDate, endDate, unit)}
/> />
<QuickButtons value={value} onChange={handleDateChange} />
</div> </div>
</div> </div>
</> </>

View File

@ -29,3 +29,15 @@
border-bottom: 1px solid var(--gray300); border-bottom: 1px solid var(--gray300);
z-index: 3; z-index: 3;
} }
.filter {
display: flex;
justify-content: flex-end;
align-items: center;
}
@media only screen and (max-width: 992px) {
.filter {
display: block;
}
}

View File

@ -1,10 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import classNames from 'classnames'; import classNames from 'classnames';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import Button from 'components/common/Button'; import Button from 'components/common/Button';
import Icon from 'components/common/Icon'; import Icon from 'components/common/Icon';
import Table from 'components/common/Table'; import Table from 'components/common/Table';
import Modal from 'components/common/Modal'; import Modal from 'components/common/Modal';
import Toast from 'components/common/Toast';
import AccountEditForm from 'components/forms/AccountEditForm'; import AccountEditForm from 'components/forms/AccountEditForm';
import ButtonLayout from 'components/layout/ButtonLayout'; import ButtonLayout from 'components/layout/ButtonLayout';
import DeleteForm from 'components/forms/DeleteForm'; import DeleteForm from 'components/forms/DeleteForm';
@ -13,11 +16,11 @@ import Pen from 'assets/pen.svg';
import Plus from 'assets/plus.svg'; import Plus from 'assets/plus.svg';
import Trash from 'assets/trash.svg'; import Trash from 'assets/trash.svg';
import Check from 'assets/check.svg'; import Check from 'assets/check.svg';
import List from 'assets/list-ul.svg';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
import Toast from '../common/Toast';
import { FormattedMessage } from 'react-intl';
export default function AccountSettings() { export default function AccountSettings() {
const router = useRouter();
const [addAccount, setAddAccount] = useState(); const [addAccount, setAddAccount] = useState();
const [editAccount, setEditAccount] = useState(); const [editAccount, setEditAccount] = useState();
const [deleteAccount, setDeleteAccount] = useState(); const [deleteAccount, setDeleteAccount] = useState();
@ -30,6 +33,13 @@ export default function AccountSettings() {
const Buttons = row => const Buttons = row =>
row.username !== 'admin' ? ( row.username !== 'admin' ? (
<ButtonLayout> <ButtonLayout>
<Button
icon={<List />}
size="small"
tooltip={<FormattedMessage id="button.websites" defaultMessage="Websites" />}
tooltipId={`button-websites-${row.username}`}
onClick={() => router.push(`/dashboard/${row.user_id}/${row.username}`)}
/>
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}> <Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
<div> <div>
<FormattedMessage id="button.edit" defaultMessage="Edit" /> <FormattedMessage id="button.edit" defaultMessage="Edit" />
@ -56,6 +66,7 @@ export default function AccountSettings() {
render: Checkmark, render: Checkmark,
}, },
{ {
key: 'actions',
className: classNames(styles.buttons, 'col-12 col-md-4 pt-2 pt-md-0'), className: classNames(styles.buttons, 'col-12 col-md-4 pt-2 pt-md-0'),
render: Buttons, render: Buttons,
}, },

View File

@ -12,12 +12,14 @@
"button.more": "Mehr", "button.more": "Mehr",
"button.save": "Speichern", "button.save": "Speichern",
"button.view-details": "Details anzeigen", "button.view-details": "Details anzeigen",
"button.websites": "Webseiten",
"footer.powered-by": "Powered by", "footer.powered-by": "Powered by",
"header.nav.dashboard": "Übersicht", "header.nav.dashboard": "Übersicht",
"header.nav.settings": "Einstellungen", "header.nav.settings": "Einstellungen",
"label.administrator": "Administrator", "label.administrator": "Administrator",
"label.confirm-password": "Passwort wiederholen", "label.confirm-password": "Passwort wiederholen",
"label.current-password": "Derzeitiges Passwort", "label.current-password": "Derzeitiges Passwort",
"label.custom-range": "Custom range",
"label.domain": "Domain", "label.domain": "Domain",
"label.enable-share-url": "Freigabe-URL aktivieren", "label.enable-share-url": "Freigabe-URL aktivieren",
"label.invalid": "Ungültig", "label.invalid": "Ungültig",
@ -42,6 +44,7 @@
"message.failure": "Es it ein Fehler aufgetreten.", "message.failure": "Es it ein Fehler aufgetreten.",
"message.incorrect-username-password": "Falsches Passwort oder Benutzername.", "message.incorrect-username-password": "Falsches Passwort oder Benutzername.",
"message.no-data-available": "Keine Daten vorhanden.", "message.no-data-available": "Keine Daten vorhanden.",
"message.page-not-found": "Seite nicht gefunden.",
"message.save-success": "Erfolgreich gespeichert.", "message.save-success": "Erfolgreich gespeichert.",
"message.share-url": "Dies ist der öffentliche URL zum Teilen für {target}.", "message.share-url": "Dies ist der öffentliche URL zum Teilen für {target}.",
"message.track-stats": "Um die Statistiken für {target} zu übermitteln, platzieren Sie bitte den folgenden Quelltext im {head} ihrer Homepage.", "message.track-stats": "Um die Statistiken für {target} zu übermitteln, platzieren Sie bitte den folgenden Quelltext im {head} ihrer Homepage.",

View File

@ -12,12 +12,14 @@
"button.more": "More", "button.more": "More",
"button.save": "Save", "button.save": "Save",
"button.view-details": "View details", "button.view-details": "View details",
"button.websites": "Websites",
"footer.powered-by": "Powered by", "footer.powered-by": "Powered by",
"header.nav.dashboard": "Dashboard", "header.nav.dashboard": "Dashboard",
"header.nav.settings": "Settings", "header.nav.settings": "Settings",
"label.administrator": "Administrator", "label.administrator": "Administrator",
"label.confirm-password": "Confirm password", "label.confirm-password": "Confirm password",
"label.current-password": "Current password", "label.current-password": "Current password",
"label.custom-range": "Custom range",
"label.domain": "Domain", "label.domain": "Domain",
"label.enable-share-url": "Enable share URL", "label.enable-share-url": "Enable share URL",
"label.invalid": "Invalid", "label.invalid": "Invalid",
@ -42,6 +44,7 @@
"message.failure": "Something went wrong.", "message.failure": "Something went wrong.",
"message.incorrect-username-password": "Incorrect username/password.", "message.incorrect-username-password": "Incorrect username/password.",
"message.no-data-available": "No data available.", "message.no-data-available": "No data available.",
"message.page-not-found": "Page not found.",
"message.save-success": "Saved successfully.", "message.save-success": "Saved successfully.",
"message.share-url": "This is the publicly shared URL for {target}.", "message.share-url": "This is the publicly shared URL for {target}.",
"message.track-stats": "To track stats for {target}, place the following code in the {head} section of your website.", "message.track-stats": "To track stats for {target}, place the following code in the {head} section of your website.",

View File

@ -12,12 +12,14 @@
"button.more": "Más", "button.more": "Más",
"button.save": "Guardar", "button.save": "Guardar",
"button.view-details": "Ver detalles", "button.view-details": "Ver detalles",
"button.websites": "Sitios",
"footer.powered-by": "Desarrollado con", "footer.powered-by": "Desarrollado con",
"header.nav.dashboard": "Panel de control", "header.nav.dashboard": "Panel de control",
"header.nav.settings": "Configuraciones", "header.nav.settings": "Configuraciones",
"label.administrator": "Administrador", "label.administrator": "Administrador",
"label.confirm-password": "Confirmar contraseña", "label.confirm-password": "Confirmar contraseña",
"label.current-password": "Contraseña actual", "label.current-password": "Contraseña actual",
"label.custom-range": "Custom range",
"label.domain": "Dominio", "label.domain": "Dominio",
"label.enable-share-url": "Habilitar compartir URL", "label.enable-share-url": "Habilitar compartir URL",
"label.invalid": "Inválido", "label.invalid": "Inválido",
@ -42,6 +44,7 @@
"message.failure": "Algo falló.", "message.failure": "Algo falló.",
"message.incorrect-username-password": "Nombre de usuario o contraseña incorrectos.", "message.incorrect-username-password": "Nombre de usuario o contraseña incorrectos.",
"message.no-data-available": "Sin información disponible.", "message.no-data-available": "Sin información disponible.",
"message.page-not-found": "Page not found",
"message.save-success": "Guardado exitosamente.", "message.save-success": "Guardado exitosamente.",
"message.share-url": "Esta es la URL compartida públicamente para {target}.", "message.share-url": "Esta es la URL compartida públicamente para {target}.",
"message.track-stats": "Para registrar estadísticas para {target}, copia el siguiente código dentro de la etiqueta {head} de tu sitio.", "message.track-stats": "Para registrar estadísticas para {target}, copia el siguiente código dentro de la etiqueta {head} de tu sitio.",

View File

@ -12,12 +12,14 @@
"button.more": "さらに表示", "button.more": "さらに表示",
"button.save": "保存", "button.save": "保存",
"button.view-details": "詳細表示", "button.view-details": "詳細表示",
"button.websites": "Webサイト",
"footer.powered-by": "Powered by", "footer.powered-by": "Powered by",
"header.nav.dashboard": "ダッシュボード", "header.nav.dashboard": "ダッシュボード",
"header.nav.settings": "設定", "header.nav.settings": "設定",
"label.administrator": "管理者", "label.administrator": "管理者",
"label.confirm-password": "パスワード(確認)", "label.confirm-password": "パスワード(確認)",
"label.current-password": "現在のパスワード", "label.current-password": "現在のパスワード",
"label.custom-range": "Custom range",
"label.domain": "ドメイン", "label.domain": "ドメイン",
"label.enable-share-url": "共有リンクを有効にする", "label.enable-share-url": "共有リンクを有効にする",
"label.invalid": "無効", "label.invalid": "無効",
@ -42,6 +44,7 @@
"message.failure": "問題が発生しました。", "message.failure": "問題が発生しました。",
"message.incorrect-username-password": "ユーザー名/パスワードが正しくありません。", "message.incorrect-username-password": "ユーザー名/パスワードが正しくありません。",
"message.no-data-available": "データがありません。", "message.no-data-available": "データがありません。",
"message.page-not-found": "ページが見つかりません。",
"message.save-success": "正常に保存されました。", "message.save-success": "正常に保存されました。",
"message.share-url": "これは {target} の共有リンクです。", "message.share-url": "これは {target} の共有リンクです。",
"message.track-stats": "{target}のアクセス解析を開始するには、次のコードをWebサイトの{head}セクションへ追加してください。", "message.track-stats": "{target}のアクセス解析を開始するには、次のコードをWebサイトの{head}セクションへ追加してください。",

View File

@ -12,12 +12,14 @@
"button.more": "Toon meer", "button.more": "Toon meer",
"button.save": "Opslaan", "button.save": "Opslaan",
"button.view-details": "Meer details", "button.view-details": "Meer details",
"button.websites": "Websites",
"footer.powered-by": "mogelijk gemaakt door", "footer.powered-by": "mogelijk gemaakt door",
"header.nav.dashboard": "Dashboard", "header.nav.dashboard": "Dashboard",
"header.nav.settings": "Instellingen", "header.nav.settings": "Instellingen",
"label.administrator": "Administrator", "label.administrator": "Administrator",
"label.confirm-password": "Wachtwoord bevestigen", "label.confirm-password": "Wachtwoord bevestigen",
"label.current-password": "Huidig wachtwoord", "label.current-password": "Huidig wachtwoord",
"label.custom-range": "Custom range",
"label.domain": "Domein", "label.domain": "Domein",
"label.enable-share-url": "Sta delen via openbare URL toe", "label.enable-share-url": "Sta delen via openbare URL toe",
"label.invalid": "Ongeldig", "label.invalid": "Ongeldig",
@ -42,6 +44,7 @@
"message.failure": "Er is iets misgegaan.", "message.failure": "Er is iets misgegaan.",
"message.incorrect-username-password": "Incorrecte gebruikersnaam/wachtwoord.", "message.incorrect-username-password": "Incorrecte gebruikersnaam/wachtwoord.",
"message.no-data-available": "Geen gegevens beschikbaar.", "message.no-data-available": "Geen gegevens beschikbaar.",
"message.page-not-found": "Pagina niet gevonden.",
"message.save-success": "Opslaan succesvol.", "message.save-success": "Opslaan succesvol.",
"message.share-url": "Met deze URL kan {target} openbaar gedeeld worden.", "message.share-url": "Met deze URL kan {target} openbaar gedeeld worden.",
"message.track-stats": "Om statistieken voor {target} bij te houden, plaats je de volgende code in het {head} gedeelte van je website.", "message.track-stats": "Om statistieken voor {target} bij te houden, plaats je de volgende code in het {head} gedeelte van je website.",

View File

@ -12,12 +12,14 @@
"button.more": "Больше", "button.more": "Больше",
"button.save": "Сохранить", "button.save": "Сохранить",
"button.view-details": "Посмотреть детали", "button.view-details": "Посмотреть детали",
"button.websites": "Сайты",
"footer.powered-by": "на движке", "footer.powered-by": "на движке",
"header.nav.dashboard": "Информационная панель", "header.nav.dashboard": "Информационная панель",
"header.nav.settings": "Настройки", "header.nav.settings": "Настройки",
"label.administrator": "Администратор", "label.administrator": "Администратор",
"label.confirm-password": "Подтвердить пароль", "label.confirm-password": "Подтвердить пароль",
"label.current-password": "Текущий пароль", "label.current-password": "Текущий пароль",
"label.custom-range": "Custom range",
"label.domain": "Домен", "label.domain": "Домен",
"label.enable-share-url": "Разрешить делиться ссылкой", "label.enable-share-url": "Разрешить делиться ссылкой",
"label.invalid": "Некорректный", "label.invalid": "Некорректный",
@ -42,6 +44,7 @@
"message.failure": "Что-то пошло не так.", "message.failure": "Что-то пошло не так.",
"message.incorrect-username-password": "Неверное имя пользователя/пароль.", "message.incorrect-username-password": "Неверное имя пользователя/пароль.",
"message.no-data-available": "Нет данных.", "message.no-data-available": "Нет данных.",
"message.page-not-found": "Страница не найдена.",
"message.save-success": "Успешно сохранено.", "message.save-success": "Успешно сохранено.",
"message.share-url": "Это публичная ссылка для {target}.", "message.share-url": "Это публичная ссылка для {target}.",
"message.track-stats": "Чтобы отслеживать статистику для {target}, поместите следующий код в раздел {head} вашего сайта.", "message.track-stats": "Чтобы отслеживать статистику для {target}, поместите следующий код в раздел {head} вашего сайта.",

View File

@ -12,12 +12,14 @@
"button.more": "Detaylı göster", "button.more": "Detaylı göster",
"button.save": "Kaydet", "button.save": "Kaydet",
"button.view-details": "Detayı incele", "button.view-details": "Detayı incele",
"button.websites": "Web siteleri",
"footer.powered-by": "Sağlayıcı:", "footer.powered-by": "Sağlayıcı:",
"header.nav.dashboard": "Kontrol Paneli", "header.nav.dashboard": "Kontrol Paneli",
"header.nav.settings": "Ayarlar", "header.nav.settings": "Ayarlar",
"label.administrator": "Yönetici", "label.administrator": "Yönetici",
"label.confirm-password": "Parolayı onayla", "label.confirm-password": "Parolayı onayla",
"label.current-password": "Mevcut parola", "label.current-password": "Mevcut parola",
"label.custom-range": "Custom range",
"label.domain": "Alan adı", "label.domain": "Alan adı",
"label.enable-share-url": "Anonim paylaşım URL'i aktif", "label.enable-share-url": "Anonim paylaşım URL'i aktif",
"label.invalid": "Geçeriz", "label.invalid": "Geçeriz",
@ -42,6 +44,7 @@
"message.failure": "Bir şeyler ters gitti!", "message.failure": "Bir şeyler ters gitti!",
"message.incorrect-username-password": "Hatalı kullanıcı adı ya da parola.", "message.incorrect-username-password": "Hatalı kullanıcı adı ya da parola.",
"message.no-data-available": "Henüz hiç veri yok.", "message.no-data-available": "Henüz hiç veri yok.",
"message.page-not-found": "Sayfa bulunamadı.",
"message.save-success": "Başarıyla kaydedildi.", "message.save-success": "Başarıyla kaydedildi.",
"message.share-url": "{target} için kullanılabilir anonim paylaşım adresidir.", "message.share-url": "{target} için kullanılabilir anonim paylaşım adresidir.",
"message.track-stats": "{target} alanı adı istatistiklerini takip etmek için, aşağıdaki kodu web sitenizin {head} bloğuna yerleştirin.", "message.track-stats": "{target} alanı adı istatistiklerini takip etmek için, aşağıdaki kodu web sitenizin {head} bloğuna yerleştirin.",

View File

@ -12,12 +12,14 @@
"button.more": "更多", "button.more": "更多",
"button.save": "保存", "button.save": "保存",
"button.view-details": "查看更多", "button.view-details": "查看更多",
"button.websites": "网站",
"footer.powered-by": "运行", "footer.powered-by": "运行",
"header.nav.dashboard": "仪表板", "header.nav.dashboard": "仪表板",
"header.nav.settings": "设置", "header.nav.settings": "设置",
"label.administrator": "管理员", "label.administrator": "管理员",
"label.confirm-password": "确认密码", "label.confirm-password": "确认密码",
"label.current-password": "目前密码", "label.current-password": "目前密码",
"label.custom-range": "Custom range",
"label.domain": "域名", "label.domain": "域名",
"label.enable-share-url": "激活共享链接", "label.enable-share-url": "激活共享链接",
"label.invalid": "输入无效", "label.invalid": "输入无效",
@ -42,6 +44,7 @@
"message.failure": "出现错误.", "message.failure": "出现错误.",
"message.incorrect-username-password": "用户名密码不正确.", "message.incorrect-username-password": "用户名密码不正确.",
"message.no-data-available": "无可用数据.", "message.no-data-available": "无可用数据.",
"message.page-not-found": "网页未找到.",
"message.save-success": "成功保存.", "message.save-success": "成功保存.",
"message.share-url": "这是 {target} 的共享链接.", "message.share-url": "这是 {target} 的共享链接.",
"message.track-stats": "把以下代码放到你的网站的{head}部分来收集{target}的数据.", "message.track-stats": "把以下代码放到你的网站的{head}部分来收集{target}的数据.",

11
lib/array.js Normal file
View File

@ -0,0 +1,11 @@
export function chunk(arr, size) {
const chunks = [];
let index = 0;
while (index < arr.length) {
chunks.push(arr.slice(index, size + index));
index += size;
}
return chunks;
}

View File

@ -4,6 +4,7 @@ import {
addHours, addHours,
addDays, addDays,
addMonths, addMonths,
addYears,
subHours, subHours,
subDays, subDays,
startOfHour, startOfHour,
@ -18,7 +19,8 @@ import {
endOfYear, endOfYear,
differenceInHours, differenceInHours,
differenceInCalendarDays, differenceInCalendarDays,
differenceInMonths, differenceInCalendarMonths,
differenceInCalendarYears,
} from 'date-fns'; } from 'date-fns';
export function getTimezone() { export function getTimezone() {
@ -85,10 +87,24 @@ export function getDateRange(value) {
} }
} }
export function getDateRangeValues(startDate, endDate) {
let unit = 'year';
if (differenceInHours(endDate, startDate) <= 72) {
unit = 'hour';
} else if (differenceInCalendarDays(endDate, startDate) <= 90) {
unit = 'day';
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
unit = 'month';
}
return { startDate: startOfDay(startDate), endDate: endOfDay(endDate), unit };
}
const dateFuncs = { const dateFuncs = {
hour: [differenceInHours, addHours, startOfHour], hour: [differenceInHours, addHours, startOfHour],
day: [differenceInCalendarDays, addDays, startOfDay], day: [differenceInCalendarDays, addDays, startOfDay],
month: [differenceInMonths, addMonths, startOfMonth], month: [differenceInCalendarMonths, addMonths, startOfMonth],
year: [differenceInCalendarYears, addYears, startOfYear],
}; };
export function getDateArray(data, startDate, endDate, unit) { export function getDateArray(data, startDate, endDate, unit) {
@ -98,11 +114,12 @@ export function getDateArray(data, startDate, endDate, unit) {
function findData(t) { function findData(t) {
const x = data.find(e => { const x = data.find(e => {
if (unit === 'day') { if (unit === 'hour') {
const [year, month, day] = e.t.split('-'); return normalize(new Date(e.t)).getTime() === t.getTime();
return normalize(new Date(year, month - 1, day)).getTime() === t.getTime();
} }
return normalize(new Date(e.t)).getTime() === t.getTime();
const [year, month, day] = e.t.split('-');
return normalize(new Date(year, month - 1, day)).getTime() === t.getTime();
}); });
return x?.y || 0; return x?.y || 0;

View File

@ -10,7 +10,7 @@ import jaMessages from 'lang-compiled/ja-JP.json';
import esMXMessages from 'lang-compiled/es-MX.json'; import esMXMessages from 'lang-compiled/es-MX.json';
export const messages = { export const messages = {
en: enMessages, 'en-US': enMessages,
'nl-NL': nlMessages, 'nl-NL': nlMessages,
'zh-CN': zhCNMessages, 'zh-CN': zhCNMessages,
'de-DE': deDEMessages, 'de-DE': deDEMessages,
@ -21,7 +21,7 @@ export const messages = {
}; };
export const dateLocales = { export const dateLocales = {
en: enUS, 'en-US': enUS,
'nl-NL': nl, 'nl-NL': nl,
'zh-CN': zhCN, 'zh-CN': zhCN,
'de-DE': de, 'de-DE': de,
@ -33,13 +33,13 @@ export const dateLocales = {
export const menuOptions = [ export const menuOptions = [
{ label: 'English', value: 'en', display: 'EN' }, { label: 'English', value: 'en', display: 'EN' },
{ label: '中文 (Chinese Simplified)', value: 'zh-CN', display: '中文' }, { label: '中文', value: 'zh-CN', display: 'CN' },
{ label: 'Nederlands (Dutch)', value: 'nl-NL', display: 'NL' }, { label: 'Deutsch', value: 'de-DE', display: 'DE' },
{ label: 'Deutsch (German)', value: 'de-DE', display: 'DE' }, { label: 'Español', value: 'es-MX', display: 'ES' },
{ label: '日本語 (Japanese)', value: 'ja-JP', display: '日本語' }, { label: '日本語', value: 'ja-JP', display: 'JP' },
{ label: 'Русский (Russian)', value: 'ru-RU', display: 'РУ' }, { label: 'Nederlands', value: 'nl-NL', display: 'NL' },
{ label: 'Русский', value: 'ru-RU', display: 'RU' },
{ label: 'Turkish', value: 'tr-TR', display: 'TR' }, { label: 'Turkish', value: 'tr-TR', display: 'TR' },
{ label: 'Español (Mexicano)', value: 'es-MX', display: 'ES' },
]; ];
export function dateFormat(date, str, locale) { export function dateFormat(date, str, locale) {

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "0.29.0", "version": "0.32.0",
"description": "A simple, fast, website analytics alternative to Google Analytics. ", "description": "A simple, fast, website analytics alternative to Google Analytics. ",
"author": "Mike Cao <mike@mikecao.com>", "author": "Mike Cao <mike@mikecao.com>",
"license": "MIT", "license": "MIT",
@ -23,10 +23,10 @@
"build-postgresql-schema": "dotenv prisma introspect -- --schema=./prisma/schema.postgresql.prisma", "build-postgresql-schema": "dotenv prisma introspect -- --schema=./prisma/schema.postgresql.prisma",
"build-postgresql-client": "dotenv prisma generate -- --schema=./prisma/schema.postgresql.prisma", "build-postgresql-client": "dotenv prisma generate -- --schema=./prisma/schema.postgresql.prisma",
"build-lang": "npm-run-all format-lang compile-lang", "build-lang": "npm-run-all format-lang compile-lang",
"extract-lang": "formatjs extract {pages,components}/**/*.js --out-file lang/en-US.json", "extract-lang": "formatjs extract {pages,components}/**/*.js --out-file build/messages.json",
"merge-lang": "node scripts/merge-lang.js", "merge-lang": "node scripts/merge-lang.js",
"format-lang": "node scripts/format-lang.js", "format-lang": "node scripts/format-lang.js",
"compile-lang": "formatjs compile-folder --ast lang-formatted lang-compiled" "compile-lang": "formatjs compile-folder --ast build lang-compiled"
}, },
"lint-staged": { "lint-staged": {
"**/*.js": [ "**/*.js": [

View File

@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import Layout from 'components/layout/Layout'; import Layout from 'components/layout/Layout';
import { FormattedMessage } from 'react-intl';
export default function Custom404() { export default function Custom404() {
return ( return (
<Layout> <Layout>
<div className="row justify-content-center"> <div className="row justify-content-center">
<h1>oops! page not found</h1> <h1>
<FormattedMessage id="message.page-not-found" defaultMessage="Page not found" />
</h1>
</div> </div>
</Layout> </Layout>
); );

View File

@ -1,18 +0,0 @@
import React from 'react';
import Layout from 'components/layout/Layout';
import AccountSettings from 'components/settings/AccountSettings';
import useRequireLogin from 'hooks/useRequireLogin';
export default function AccountPage() {
const { loading } = useRequireLogin();
if (loading) {
return null;
}
return (
<Layout>
<AccountSettings />
</Layout>
);
}

View File

@ -9,24 +9,20 @@ export default async (req, res) => {
const { id } = req.query; const { id } = req.query;
const user_id = +id; const user_id = +id;
if (req.method === 'GET') { if (is_admin) {
if (is_admin) {
const account = await getAccountById(user_id);
return ok(res, account);
}
return unauthorized(res); return unauthorized(res);
} }
if (req.method === 'GET') {
const account = await getAccountById(user_id);
return ok(res, account);
}
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
if (is_admin) { await deleteAccount(user_id);
await deleteAccount(user_id);
return ok(res); return ok(res);
}
return unauthorized(res);
} }
return methodNotAllowed(res); return methodNotAllowed(res);

View File

@ -1,14 +1,18 @@
import { getAccountById, updateAccount } from 'lib/queries'; import { getAccountById, updateAccount } from 'lib/queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok } from 'lib/response'; import { badRequest, methodNotAllowed, ok, unauthorized } from 'lib/response';
import { checkPassword, hashPassword } from 'lib/crypto'; import { checkPassword, hashPassword } from 'lib/crypto';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
const { user_id } = req.auth; const { user_id, is_admin } = req.auth;
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
if (is_admin) {
return unauthorized(res);
}
if (req.method === 'POST') { if (req.method === 'POST') {
const account = await getAccountById(user_id); const account = await getAccountById(user_id);
const valid = await checkPassword(current_password, account.password); const valid = await checkPassword(current_password, account.password);

View File

@ -1,11 +1,18 @@
import { getActiveVisitors } from 'lib/queries'; import { getActiveVisitors } from 'lib/queries';
import { ok } from 'lib/response'; import { methodNotAllowed, ok } from 'lib/response';
import { useAuth } from 'lib/middleware';
export default async (req, res) => { export default async (req, res) => {
const { id } = req.query; await useAuth(req, res);
const website_id = +id;
const result = await getActiveVisitors(website_id); if (req.method === 'GET') {
const { id } = req.query;
const website_id = +id;
return ok(res, result); const result = await getActiveVisitors(website_id);
return ok(res, result);
}
return methodNotAllowed(res);
}; };

View File

@ -1,21 +1,28 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { getEvents } from 'lib/queries'; import { getEvents } from 'lib/queries';
import { ok, badRequest } from 'lib/response'; import { ok, badRequest, methodNotAllowed } from 'lib/response';
import { useAuth } from 'lib/middleware';
const unitTypes = ['month', 'hour', 'day']; const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => { export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query; await useAuth(req, res);
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) { if (req.method === 'GET') {
return badRequest(res); const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEvents(websiteId, startDate, endDate, tz, unit);
return ok(res, events);
} }
const websiteId = +id; return methodNotAllowed(res);
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEvents(websiteId, startDate, endDate, tz, unit);
return ok(res, events);
}; };

View File

@ -1,18 +1,25 @@
import { getMetrics } from 'lib/queries'; import { getMetrics } from 'lib/queries';
import { ok } from 'lib/response'; import { methodNotAllowed, ok } from 'lib/response';
import { useAuth } from 'lib/middleware';
export default async (req, res) => { export default async (req, res) => {
const { id, start_at, end_at } = req.query; await useAuth(req, res);
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const metrics = await getMetrics(websiteId, startDate, endDate); if (req.method === 'GET') {
const { id, start_at, end_at } = req.query;
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const stats = Object.keys(metrics[0]).reduce((obj, key) => { const metrics = await getMetrics(websiteId, startDate, endDate);
obj[key] = Number(metrics[0][key]) || 0;
return obj;
}, {});
return ok(res, stats); const stats = Object.keys(metrics[0]).reduce((obj, key) => {
obj[key] = Number(metrics[0][key]) || 0;
return obj;
}, {});
return ok(res, stats);
}
return methodNotAllowed(res);
}; };

View File

@ -1,24 +1,31 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { getPageviews } from 'lib/queries'; import { getPageviews } from 'lib/queries';
import { ok, badRequest } from 'lib/response'; import { ok, badRequest, methodNotAllowed } from 'lib/response';
import { useAuth } from 'lib/middleware';
const unitTypes = ['month', 'hour', 'day']; const unitTypes = ['year', 'month', 'hour', 'day'];
export default async (req, res) => { export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query; await useAuth(req, res);
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) { if (req.method === 'GET') {
return badRequest(res); const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const [pageviews, uniques] = await Promise.all([
getPageviews(websiteId, startDate, endDate, tz, unit, '*'),
getPageviews(websiteId, startDate, endDate, tz, unit, 'distinct session_id'),
]);
return ok(res, { pageviews, uniques });
} }
const websiteId = +id; return methodNotAllowed(res);
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const [pageviews, uniques] = await Promise.all([
getPageviews(websiteId, startDate, endDate, tz, unit, '*'),
getPageviews(websiteId, startDate, endDate, tz, unit, 'distinct session_id'),
]);
return ok(res, { pageviews, uniques });
}; };

View File

@ -1,6 +1,7 @@
import { getRankings } from 'lib/queries'; import { getRankings } from 'lib/queries';
import { ok, badRequest } from 'lib/response'; import { ok, badRequest, methodNotAllowed } from 'lib/response';
import { DOMAIN_REGEX } from '../../../../lib/constants'; import { DOMAIN_REGEX } from 'lib/constants';
import { useAuth } from 'lib/middleware';
const sessionColumns = ['browser', 'os', 'device', 'country']; const sessionColumns = ['browser', 'os', 'device', 'country'];
const pageviewColumns = ['url', 'referrer']; const pageviewColumns = ['url', 'referrer'];
@ -25,29 +26,35 @@ function getColumn(type) {
} }
export default async (req, res) => { export default async (req, res) => {
const { id, type, start_at, end_at, domain } = req.query; await useAuth(req, res);
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
if ( if (req.method === 'GET') {
type !== 'event' && const { id, type, start_at, end_at, domain } = req.query;
!sessionColumns.includes(type) && const websiteId = +id;
!pageviewColumns.includes(type) && const startDate = new Date(+start_at);
domain && const endDate = new Date(+end_at);
DOMAIN_REGEX.test(domain)
) { if (
return badRequest(res); type !== 'event' &&
!sessionColumns.includes(type) &&
!pageviewColumns.includes(type) &&
domain &&
DOMAIN_REGEX.test(domain)
) {
return badRequest(res);
}
const rankings = await getRankings(
websiteId,
startDate,
endDate,
getColumn(type),
getTable(type),
domain,
);
return ok(res, rankings);
} }
const rankings = await getRankings( return methodNotAllowed(res);
websiteId,
startDate,
endDate,
getColumn(type),
getTable(type),
domain,
);
return ok(res, rankings);
}; };

View File

@ -1,14 +1,19 @@
import { getUserWebsites } from 'lib/queries'; import { getUserWebsites } from 'lib/queries';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed } from 'lib/response'; import { ok, methodNotAllowed, unauthorized } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
const { user_id } = req.auth; const { user_id, is_admin } = req.auth;
const { userId } = req.query;
if (req.method === 'GET') { if (req.method === 'GET') {
const websites = await getUserWebsites(user_id); if (userId && !is_admin) {
return unauthorized(res);
}
const websites = await getUserWebsites(+userId || user_id);
return ok(res, websites); return ok(res, websites);
} }

View File

@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout'; import Layout from 'components/layout/Layout';
import WebsiteList from 'components/WebsiteList'; import WebsiteList from 'components/WebsiteList';
import useRequireLogin from 'hooks/useRequireLogin'; import useRequireLogin from 'hooks/useRequireLogin';
export default function DashboardPage() { export default function DashboardPage() {
const { loading } = useRequireLogin(); const { loading } = useRequireLogin();
const router = useRouter();
const { id } = router.query;
const userId = id?.[0];
if (loading) { if (loading) {
return null; return null;
@ -12,7 +16,7 @@ export default function DashboardPage() {
return ( return (
<Layout> <Layout>
<WebsiteList /> <WebsiteList userId={userId} />
</Layout> </Layout>
); );
} }

View File

@ -3,7 +3,7 @@ const path = require('path');
const prettier = require('prettier'); const prettier = require('prettier');
const src = path.resolve(__dirname, '../lang'); const src = path.resolve(__dirname, '../lang');
const dest = path.resolve(__dirname, '../lang-formatted'); const dest = path.resolve(__dirname, '../build');
const files = fs.readdirSync(src); const files = fs.readdirSync(src);
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {

View File

@ -1,11 +1,11 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const prettier = require('prettier'); const prettier = require('prettier');
const root = require('../lang/en-US.json'); const messages = require('../build/messages.json');
const dir = path.resolve(__dirname, '../lang'); const dest = path.resolve(__dirname, '../lang');
const files = fs.readdirSync(dir); const files = fs.readdirSync(dest);
const keys = Object.keys(root).sort(); const keys = Object.keys(messages).sort();
files.forEach(file => { files.forEach(file => {
const lang = require(`../lang/${file}`); const lang = require(`../lang/${file}`);
@ -15,7 +15,7 @@ files.forEach(file => {
const merged = keys.reduce((obj, key) => { const merged = keys.reduce((obj, key) => {
const message = lang[key]; const message = lang[key];
obj[key] = message || root[key]; obj[key] = message || messages[key].defaultMessage;
if (!message) { if (!message) {
console.log(`* Added key ${key}`); console.log(`* Added key ${key}`);
@ -26,5 +26,5 @@ files.forEach(file => {
const json = prettier.format(JSON.stringify(merged), { parser: 'json' }); const json = prettier.format(JSON.stringify(merged), { parser: 'json' });
fs.writeFileSync(path.resolve(dir, file), json); fs.writeFileSync(path.resolve(dest, file), json);
}); });

View File

@ -15,7 +15,10 @@ body {
.zh-CN { .zh-CN {
font-family: 'Noto Sans SC', sans-serif !important; font-family: 'Noto Sans SC', sans-serif !important;
font-size: 110%; }
.ja-JP {
font-family: 'Noto Sans JP', sans-serif !important;
} }
*, *,
@ -40,6 +43,10 @@ h6 {
height: 100%; height: 100%;
} }
#__modals {
z-index: 10;
}
button, button,
input, input,
select { select {