diff --git a/components/common/Button.js b/components/common/Button.js index 2bdbee5b..d72fb662 100644 --- a/components/common/Button.js +++ b/components/common/Button.js @@ -13,6 +13,8 @@ export default function Button({ className, tooltip, tooltipId, + disabled = false, + onClick = () => {}, ...props }) { return ( @@ -27,7 +29,10 @@ export default function Button({ [styles.xsmall]: size === 'xsmall', [styles.action]: variant === 'action', [styles.danger]: variant === 'danger', + [styles.disabled]: disabled, })} + disabled={disabled} + onClick={!disabled ? onClick : null} {...props} > {icon && } diff --git a/components/common/Button.module.css b/components/common/Button.module.css index 4bf5e05a..99c7168f 100644 --- a/components/common/Button.module.css +++ b/components/common/Button.module.css @@ -14,7 +14,7 @@ } .button:hover { - background: #eaeaea; + background: var(--gray200); } .button:active { @@ -38,19 +38,32 @@ } .action { - color: var(--gray50) !important; - background: var(--gray900) !important; + color: var(--gray50); + background: var(--gray900); } .action:hover { - background: var(--gray800) !important; + background: var(--gray800); } .danger { - color: var(--gray50) !important; - background: var(--red500) !important; + color: var(--gray50); + background: var(--red500); } .danger:hover { - background: var(--red400) !important; + background: var(--red400); +} + +.button:disabled { + color: var(--gray500); + background: var(--gray75); +} + +.button:disabled:active { + color: var(--gray500); +} + +.button:disabled:hover { + background: var(--gray75); } diff --git a/components/common/Calendar.js b/components/common/Calendar.js new file mode 100644 index 00000000..8ecf76a8 --- /dev/null +++ b/components/common/Calendar.js @@ -0,0 +1,258 @@ +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 ( +
+
+
{date.getDate()}
+
+ {month} + : } size="small" /> +
+
+ {year} + : } size="small" /> +
+
+ {!selectMonth && !selectYear && ( + + )} + {selectMonth && ( + + )} + {selectYear && ( + + )} +
+ ); +} + +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 ( + + + + {daysOfWeek.map((day, i) => ( + + ))} + + + + {chunk(days, 7).map((week, i) => ( + + {week.map((day, j) => { + const disabled = isBefore(day, minDate) || isAfter(day, maxDate); + return ( + + ); + })} + + ))} + +
+ {dateFormat(day, 'EEE', locale)} +
onSelect(day) : null} + > + {day.getDate()} +
+ ); +}; + +const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => { + const start = startOfYear(date); + const months = []; + for (let i = 0; i < 12; i++) { + months.push(endOfMonth(addMonths(start, i))); + } + + function handleSelect(value) { + onSelect(setMonth(date, value)); + } + + return ( + + + {chunk(months, 3).map((row, i) => ( + + {row.map((month, j) => { + const disabled = isBefore(month, minDate) || isAfter(month, maxDate); + return ( + + ); + })} + + ))} + +
handleSelect(month.getMonth()) : null} + > + {dateFormat(month, 'MMMM', locale)} +
+ ); +}; + +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 ( +
+
+ ); +}; diff --git a/components/common/Calendar.module.css b/components/common/Calendar.module.css new file mode 100644 index 00000000..ccfde683 --- /dev/null +++ b/components/common/Calendar.module.css @@ -0,0 +1,84 @@ +.calendar { + display: flex; + flex-direction: column; + font-size: var(--font-size-small); + flex: 1; +} + +.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(--gray300); + background: var(--gray75); +} + +.calendar td.disabled:hover { + cursor: default; + background: var(--gray75); +} + +.calendar td.faded.disabled { + color: var(--gray200); +} + +.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; +} diff --git a/components/common/DateFilter.js b/components/common/DateFilter.js index aaba8725..a7ac6372 100644 --- a/components/common/DateFilter.js +++ b/components/common/DateFilter.js @@ -1,7 +1,12 @@ -import React from 'react'; -import { getDateRange } from 'lib/date'; -import DropDown from './DropDown'; +import React, { useState } from 'react'; 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'; const filterOptions = [ { @@ -35,14 +40,53 @@ const filterOptions = [ value: '1month', }, { label: , value: '1year' }, + { + label: , + value: 'custom', + }, ]; -export default function DateFilter({ value, onChange, className }) { +export default function DateFilter({ value, startDate, endDate, onChange, className }) { + const [locale] = useLocale(); + const [showPicker, setShowPicker] = useState(false); + const displayValue = + value === 'custom' + ? `${dateFormat(startDate, 'd LLL y', locale)} — ${dateFormat(endDate, 'd LLL y', locale)}` + : value; + function handleChange(value) { + if (value === 'custom') { + setShowPicker(true); + return; + } onChange(getDateRange(value)); } + function handlePickerChange(value) { + setShowPicker(false); + onChange(value); + } + return ( - + <> + + {showPicker && ( + + setShowPicker(false)} + /> + + )} + ); } diff --git a/components/common/DropDown.js b/components/common/DropDown.js index 8f1ca4f9..03fa8bb6 100644 --- a/components/common/DropDown.js +++ b/components/common/DropDown.js @@ -23,9 +23,8 @@ export default function DropDown({ function handleSelect(selected, e) { e.stopPropagation(); setShowMenu(false); - if (selected !== value) { - onChange(selected); - } + + onChange(selected); } useDocumentClick(e => { @@ -37,7 +36,7 @@ export default function DropDown({ return (
- {options.find(e => e.value === value)?.label} +
{options.find(e => e.value === value)?.label || value}
} size="small" />
{showMenu && ( diff --git a/components/common/Dropdown.module.css b/components/common/Dropdown.module.css index ec63552f..958305e7 100644 --- a/components/common/Dropdown.module.css +++ b/components/common/Dropdown.module.css @@ -1,16 +1,15 @@ .dropdown { + flex: 1; position: relative; - font-size: var(--font-size-small); - min-width: 140px; + border: 1px solid var(--gray500); + border-radius: 4px; + cursor: pointer; } .value { display: flex; justify-content: space-between; - white-space: nowrap; - position: relative; + font-size: var(--font-size-small); + min-width: 140px; padding: 4px 16px; - border: 1px solid var(--gray500); - border-radius: 4px; - cursor: pointer; } diff --git a/components/common/Icon.js b/components/common/Icon.js index 1b6fd1d9..8a794f61 100644 --- a/components/common/Icon.js +++ b/components/common/Icon.js @@ -2,7 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import styles from './Icon.module.css'; -export default function Icon({ icon, className, size = 'medium' }) { +export default function Icon({ icon, className, size = 'medium', ...props }) { return (
{icon}
diff --git a/components/common/LanguageButton.js b/components/common/LanguageButton.js index 74c55ba3..714ae7f6 100644 --- a/components/common/LanguageButton.js +++ b/components/common/LanguageButton.js @@ -35,7 +35,7 @@ export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'l rel="stylesheet" /> )} - {locale === 'jp-JP' && ( + {locale === 'ja-JP' && ( +
+ + +
+ + + + +
+ ); +} diff --git a/components/forms/DatePickerForm.module.css b/components/forms/DatePickerForm.module.css new file mode 100644 index 00000000..dcedc17a --- /dev/null +++ b/components/forms/DatePickerForm.module.css @@ -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; + } +} diff --git a/components/forms/DeleteForm.js b/components/forms/DeleteForm.js index 1ba81626..f53b286f 100644 --- a/components/forms/DeleteForm.js +++ b/components/forms/DeleteForm.js @@ -10,10 +10,12 @@ import FormLayout, { } from 'components/layout/FormLayout'; import { FormattedMessage } from 'react-intl'; +const CONFIRMATION_WORD = 'DELETE'; + const validate = ({ confirmation }) => { const errors = {}; - if (confirmation !== 'DELETE') { + if (confirmation !== CONFIRMATION_WORD) { errors.confirmation = !confirmation ? ( ) : ( @@ -44,7 +46,7 @@ export default function DeleteForm({ values, onSave, onClose }) { validate={validate} onSubmit={handleSubmit} > - {() => ( + {props => (
DELETE }} + values={{ delete: {CONFIRMATION_WORD} }} />

@@ -71,7 +73,11 @@ export default function DeleteForm({ values, onSave, onClose }) { -
diff --git a/lib/array.js b/lib/array.js new file mode 100644 index 00000000..5b9aa1f2 --- /dev/null +++ b/lib/array.js @@ -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; +} diff --git a/lib/date.js b/lib/date.js index 13c2e55f..c8d092d4 100644 --- a/lib/date.js +++ b/lib/date.js @@ -18,7 +18,7 @@ import { endOfYear, differenceInHours, differenceInCalendarDays, - differenceInMonths, + differenceInCalendarMonths, } from 'date-fns'; export function getTimezone() { @@ -85,10 +85,21 @@ export function getDateRange(value) { } } +export function getDateRangeValues(startDate, endDate) { + if (differenceInHours(endDate, startDate) <= 48) { + return { startDate: startOfHour(startDate), endDate: endOfHour(endDate), unit: 'hour' }; + } else if (differenceInCalendarDays(endDate, startDate) <= 90) { + return { startDate: startOfDay(startDate), endDate: endOfDay(endDate), unit: 'day' }; + } else if (differenceInCalendarMonths(endDate, startDate) <= 12) { + return { startDate: startOfMonth(startDate), endDate: endOfMonth(endDate), unit: 'month' }; + } + return { startDate: startOfYear(startDate), endDate: endOfYear(endDate), unit: 'year' }; +} + const dateFuncs = { hour: [differenceInHours, addHours, startOfHour], day: [differenceInCalendarDays, addDays, startOfDay], - month: [differenceInMonths, addMonths, startOfMonth], + month: [differenceInCalendarMonths, addMonths, startOfMonth], }; export function getDateArray(data, startDate, endDate, unit) { diff --git a/lib/lang.js b/lib/lang.js index 0bff776d..bbc257af 100644 --- a/lib/lang.js +++ b/lib/lang.js @@ -9,7 +9,7 @@ import deDEMessages from 'lang-compiled/de-DE.json'; import jaMessages from 'lang-compiled/ja-JP.json'; export const messages = { - en: enMessages, + 'en-US': enMessages, 'nl-NL': nlMessages, 'zh-CN': zhCNMessages, 'de-DE': deDEMessages, @@ -19,7 +19,7 @@ export const messages = { }; export const dateLocales = { - en: enUS, + 'en-US': enUS, 'nl-NL': nl, 'zh-CN': zhCN, 'de-DE': de, diff --git a/package.json b/package.json index 67418849..e87212c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "0.29.0", + "version": "0.30.0", "description": "A simple, fast, website analytics alternative to Google Analytics. ", "author": "Mike Cao ", "license": "MIT", diff --git a/styles/index.css b/styles/index.css index c8e1709e..5435364f 100644 --- a/styles/index.css +++ b/styles/index.css @@ -15,7 +15,10 @@ body { .zh-CN { 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%; } +#__modals { + z-index: 10; +} + button, input, select {