Merge pull request #1 from mikecao/master

Update
This commit is contained in:
Rasmus P 2020-11-04 18:22:14 +01:00 committed by GitHub
commit 534d5e5ce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
236 changed files with 6733 additions and 2713 deletions

4
.gitignore vendored
View File

@ -16,13 +16,15 @@
# production
/build
/public/umami.js
/public/geo
/lang-compiled
/lang-formatted
# misc
.DS_Store
.idea
*.iml
*.log
.vscode/*
# debug
npm-debug.log*

View File

@ -1,15 +1,41 @@
FROM node:12.18-alpine
# Build image
FROM node:12.18-alpine AS build
ARG DATABASE_TYPE
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
DATABASE_TYPE=$DATABASE_TYPE
WORKDIR /build
COPY . /app
RUN yarn config set --home enableTelemetry 0
COPY package.json yarn.lock /build/
# Install only the production dependencies
RUN yarn install --production --frozen-lockfile
# Cache these modules for production
RUN cp -R node_modules/ prod_node_modules/
# Install development dependencies
RUN yarn install --frozen-lockfile
COPY . /build
RUN yarn next telemetry disable
RUN yarn build
# Production image
FROM node:12.18-alpine AS production
WORKDIR /app
RUN npm install && npm run build
# Copy cached dependencies
COPY --from=build /build/prod_node_modules ./node_modules
# Copy generated Prisma client
COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/
COPY --from=build /build/yarn.lock /build/package.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
USER node
EXPOSE 3000
CMD ["npm", "start"]
CMD ["yarn", "start"]

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: npm run start-env

1
assets/bolt.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

1
assets/external-link.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M497.6,0,334.4.17A14.4,14.4,0,0,0,320,14.57V47.88a14.4,14.4,0,0,0,14.69,14.4l73.63-2.72,2.06,2.06L131.52,340.49a12,12,0,0,0,0,17l23,23a12,12,0,0,0,17,0L450.38,101.62l2.06,2.06-2.72,73.63A14.4,14.4,0,0,0,464.12,192h33.31a14.4,14.4,0,0,0,14.4-14.4L512,14.4A14.4,14.4,0,0,0,497.6,0ZM432,288H416a16,16,0,0,0-16,16V458a6,6,0,0,1-6,6H54a6,6,0,0,1-6-6V118a6,6,0,0,1,6-6H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V304A16,16,0,0,0,432,288Z"/></svg>

After

Width:  |  Height:  |  Size: 575 B

1
assets/eye.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z"/></svg>

After

Width:  |  Height:  |  Size: 509 B

View File

@ -1 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11"><defs><style>.cls-1{fill:#fff;stroke:#000;stroke-miterlimit:10;stroke-width:20px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><circle class="cls-1" cx="214.15" cy="181" r="171"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11">
<circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></svg>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 377 B

1
assets/moon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400"><path d="M562.44,837.55C335.89,611,288.08,273.54,418.71,0A734.31,734.31,0,0,0,215.54,143.73c-287.39,287.39-287.39,753.33,0,1040.72s753.33,287.4,1040.74,0A733.8,733.8,0,0,0,1400,981.29C1126.45,1111.92,789,1064.09,562.44,837.55Z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

1
assets/sun.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400"><path d="M367.43,422.13a54.44,54.44,0,0,1-38.66-16L205,282.35A54.69,54.69,0,0,1,282.37,205L406.11,328.79a54.68,54.68,0,0,1-38.68,93.34Z"/><path d="M1156.3,1211a54.51,54.51,0,0,1-38.67-16L993.89,1071.21a54.68,54.68,0,1,1,77.34-77.33L1195,1117.65A54.7,54.7,0,0,1,1156.3,1211Z"/><path d="M243.7,1211A54.7,54.7,0,0,1,205,1117.65L328.74,993.89a54.69,54.69,0,0,1,77.36,77.32L282.37,1195A54.51,54.51,0,0,1,243.7,1211Z"/><path d="M1032.57,422.13a54.68,54.68,0,0,1-38.68-93.34L1117.61,205A54.69,54.69,0,0,1,1195,282.35L1071.23,406.11A54.44,54.44,0,0,1,1032.57,422.13Z"/><path d="M229.69,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M1345.31,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M700,1400a54.68,54.68,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.68,54.68,0,0,1,700,1400Z"/><path d="M700,284.38a54.7,54.7,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.7,54.7,0,0,1,700,284.38Z"/><circle cx="700" cy="700" r="306.25"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
assets/visitor.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>

After

Width:  |  Height:  |  Size: 336 B

View File

@ -1,59 +0,0 @@
.chart {
margin-bottom: 30px;
}
.view {
border-top: 1px solid var(--gray300);
}
.menu {
font-size: var(--font-size-small);
}
.content {
min-height: 600px;
}
.backButton {
align-self: flex-start;
margin-bottom: 16px;
}
.backButton svg {
transform: rotate(180deg);
}
.row {
border-top: 1px solid var(--gray300);
min-height: 430px;
}
.row > [class*='col-'] {
border-left: 1px solid var(--gray300);
padding: 0 20px;
}
.row > [class*='col-']:first-child {
border-left: 0;
padding-left: 0;
}
.row > [class*='col-']:last-child {
padding-right: 0;
}
.hidden {
display: none;
}
@media only screen and (max-width: 992px) {
.row {
border: 0;
}
.row > [class*='col-'] {
border-top: 1px solid var(--gray300);
border-left: 0;
padding: 0;
}
}

View File

@ -13,7 +13,8 @@ export default function Button({
className,
tooltip,
tooltipId,
disabled = false,
disabled,
iconRight,
onClick = () => {},
...props
}) {
@ -30,14 +31,14 @@ export default function Button({
[styles.action]: variant === 'action',
[styles.danger]: variant === 'danger',
[styles.light]: variant === 'light',
[styles.disabled]: disabled,
[styles.iconRight]: iconRight,
})}
disabled={disabled}
onClick={!disabled ? onClick : null}
{...props}
>
{icon && <Icon icon={icon} size={size} />}
{children}
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
{children && <div className={styles.label}>{children}</div>}
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
</button>
);

View File

@ -3,13 +3,13 @@
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;
white-space: nowrap;
position: relative;
}
@ -18,17 +18,20 @@
}
.button:active {
color: initial;
color: var(--gray900);
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
}
.large {
font-size: var(--font-size-large);
}
.medium {
font-size: var(--font-size-normal);
}
.small {
font-size: var(--font-size-small);
}
@ -37,7 +40,8 @@
font-size: var(--font-size-xsmall);
}
.action {
.action,
.action:active {
color: var(--gray50);
background: var(--gray900);
}
@ -46,7 +50,8 @@
background: var(--gray800);
}
.danger {
.danger,
.danger:active {
color: var(--gray50);
background: var(--red500);
}
@ -55,12 +60,27 @@
background: var(--red400);
}
.light {
background: var(--gray50);
.light,
.light:active {
color: var(--gray900);
background: transparent;
}
.light:hover {
background: var(--gray75);
background: inherit;
}
.button .icon + * {
margin-left: 10px;
}
.button.iconRight .icon {
order: 1;
margin-left: 10px;
}
.button.iconRight .icon + * {
margin: 0;
}
.button:disabled {

View File

@ -7,6 +7,7 @@
.group .button {
border-radius: 0;
color: var(--gray800);
background: var(--gray50);
border-left: 1px solid var(--gray500);
padding: 4px 8px;
@ -24,6 +25,7 @@
margin: 0;
}
.selected {
.group .button.selected {
color: var(--gray900);
font-weight: 600;
}

View File

@ -17,7 +17,7 @@
text-align: center;
vertical-align: center;
height: 40px;
min-width: 40px;
width: 40px;
border-radius: 5px;
border: 1px solid transparent;
}
@ -103,3 +103,9 @@
.icon {
margin-left: 10px;
}
@media only screen and (max-width: 992px) {
.calendar table {
max-width: calc(100vw - 30px);
}
}

View File

@ -3,7 +3,7 @@ import Button from './Button';
import { FormattedMessage } from 'react-intl';
const defaultText = (
<FormattedMessage id="button.copy-to-clipboard" defaultMessage="Copy to clipboard" />
<FormattedMessage id="label.copy-to-clipboard" defaultMessage="Copy to clipboard" />
);
export default function CopyButton({ element, ...props }) {

17
components/common/Dot.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Dot.module.css';
export default 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>
);
}

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

@ -15,6 +15,7 @@ export default function DropDown({
}) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleShowMenu() {
setShowMenu(state => !state);
@ -36,11 +37,17 @@ export default function DropDown({
return (
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}>
{options.find(e => e.value === value)?.label || 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} onSelect={handleSelect} float="bottom" />
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float="bottom"
/>
)}
</div>
);

View File

@ -19,6 +19,10 @@
min-width: 160px;
}
.icon {
padding-left: 10px;
.text {
flex: 1;
}
.icon {
padding-left: 20px;
}

View File

@ -6,7 +6,7 @@ import styles from './EmptyPlaceholder.module.css';
export default function EmptyPlaceholder({ msg, children }) {
return (
<div className={styles.placeholder}>
<Icon icon={<Logo />} size="xlarge" />
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
<h2>{msg}</h2>
{children}
</div>

View File

@ -5,3 +5,7 @@
align-items: center;
min-height: 600px;
}
.icon {
margin-bottom: 30px;
}

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,13 @@
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: auto;
display: flex;
z-index: 1;
}
.icon {
margin-right: 10px;
}

View File

@ -0,0 +1,21 @@
import React from 'react';
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;
}
export default 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;
}

View File

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

View File

@ -0,0 +1,11 @@
import React from 'react';
import ButtonLayout from 'components/layout/ButtonLayout';
import ButtonGroup from './ButtonGroup';
export default function FilterButtons({ buttons, selected, onClick }) {
return (
<ButtonLayout>
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
</ButtonLayout>
);
}

View File

@ -5,10 +5,6 @@
vertical-align: middle;
}
.icon + * {
margin-left: 10px;
}
.icon svg {
fill: currentColor;
}

View File

@ -1,66 +0,0 @@
import React, { useState, useRef } from 'react';
import Head from 'next/head';
import Menu from './Menu';
import Button from './Button';
import { menuOptions } from 'lib/lang';
import { setItem } from 'lib/web';
import useLocale from 'hooks/useLocale';
import useDocumentClick from 'hooks/useDocumentClick';
import Globe from 'assets/globe.svg';
import styles from './LanguageButton.module.css';
export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'left' }) {
const [showMenu, setShowMenu] = useState(false);
const [locale, setLocale] = useLocale();
const ref = useRef();
const selectedLocale = menuOptions.find(e => e.value === locale)?.display;
function handleSelect(value) {
setLocale(value);
setItem('umami.locale', value);
setShowMenu(false);
}
function toggleMenu() {
setShowMenu(state => !state);
}
useDocumentClick(e => {
if (!ref.current.contains(e.target)) {
setShowMenu(false);
}
});
return (
<>
<Head>
{locale === 'zh-CN' && (
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap"
rel="stylesheet"
/>
)}
{locale === 'ja-JP' && (
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap"
rel="stylesheet"
/>
)}
</Head>
<div ref={ref} className={styles.container}>
<Button icon={<Globe />} onClick={toggleMenu} size="small">
<div>{selectedLocale}</div>
</Button>
{showMenu && (
<Menu
className={styles.menu}
options={menuOptions}
onSelect={handleSelect}
float={menuPosition}
align={menuAlign}
/>
)}
</div>
</>
);
}

View File

@ -1,9 +0,0 @@
.container {
display: flex;
position: relative;
cursor: pointer;
}
.menu {
z-index: 100;
}

View File

@ -1,12 +1,23 @@
import React from 'react';
import classNames from 'classnames';
import NextLink from 'next/link';
import Icon from './Icon';
import styles from './Link.module.css';
export default function Link({ className, children, ...props }) {
export default function Link({ className, icon, children, size, iconRight, ...props }) {
return (
<NextLink {...props}>
<a className={classNames(styles.link, className)}>{children}</a>
<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>
);
}

View File

@ -2,8 +2,10 @@ a.link,
a.link:active,
a.link:visited {
position: relative;
color: #2c2c2c;
color: var(--gray900);
text-decoration: none;
display: inline-flex;
align-items: center;
}
a.link:before {
@ -12,7 +14,7 @@ a.link:before {
bottom: -2px;
width: 0;
height: 2px;
background: #2680eb;
background: var(--primary400);
opacity: 0.5;
transition: width 100ms;
}
@ -21,3 +23,28 @@ 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

@ -12,6 +12,8 @@
.loading {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
left: 50%;

View File

@ -33,7 +33,8 @@ export default function Menu({
<div
key={value}
className={classNames(styles.option, optionClassName, customClassName, {
[selectedClassName]: selectedOption === value,
[selectedClassName]: selectedOption === option,
[styles.selected]: selectedOption === option,
[styles.divider]: divider,
})}
onClick={e => onSelect(value, e)}

View File

@ -1,21 +1,22 @@
.menu {
background: var(--gray50);
border: 1px solid var(--gray500);
border-radius: 4px;
overflow: hidden;
z-index: 2;
z-index: 100;
}
.option {
font-size: var(--font-size-small);
font-weight: normal;
background: #fff;
background: var(--gray50);
padding: 4px 16px;
cursor: pointer;
white-space: nowrap;
}
.option:hover {
background: #f5f5f5;
background: var(--gray100);
}
.float {
@ -44,3 +45,7 @@
.divider {
border-top: 1px solid var(--gray300);
}
.selected {
font-weight: 600;
}

View File

@ -0,0 +1,60 @@
import React, { useState, useRef } from 'react';
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';
export default function MenuButton({
icon,
value,
options,
buttonClassName,
menuClassName,
menuPosition = 'bottom',
menuAlign = 'right',
onSelect,
renderValue,
}) {
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"
>
<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>
);
}

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

@ -16,8 +16,8 @@
right: 0;
bottom: 0;
margin: auto;
background: var(--gray900);
opacity: 0.1;
background: #000;
opacity: 0.5;
}
.content {

View File

@ -1,4 +1,5 @@
.menu {
color: var(--gray800);
border: 1px solid var(--gray500);
border-radius: 4px;
overflow: hidden;
@ -16,5 +17,6 @@
}
.selected {
color: var(--gray900);
font-weight: 600;
}

View File

@ -1,5 +1,6 @@
.container {
color: var(--gray500);
font-size: var(--font-size-normal);
position: absolute;
top: 50%;
left: 50%;

View File

@ -10,9 +10,9 @@ import { getDateRange } from '../../lib/date';
export default function RefreshButton({ websiteId }) {
const dispatch = useDispatch();
const dateRange = useDateRange(websiteId);
const [dateRange] = useDateRange(websiteId);
const [loading, setLoading] = useState(false);
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/metrics`]);
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]);
function handleClick() {
if (dateRange) {
@ -28,7 +28,7 @@ export default function RefreshButton({ websiteId }) {
return (
<Button
icon={loading ? <Dots /> : <Refresh />}
tooltip={<FormattedMessage id="button.refresh" defaultMessage="Refresh" />}
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
tooltipId="button-refresh"
size="small"
onClick={handleClick}

View File

@ -3,13 +3,23 @@ import classNames from 'classnames';
import NoData from 'components/common/NoData';
import styles from './Table.module.css';
export default function Table({ columns, rows, empty }) {
export default function Table({
columns,
rows,
empty,
className,
bodyClassName,
rowKey,
showHeader = true,
children,
}) {
if (empty && rows.length === 0) {
return empty;
}
return (
<div className={styles.table}>
<div className={classNames(styles.table, className)}>
{showHeader && (
<div className={classNames(styles.header, 'row')}>
{columns.map(({ key, label, className, style, header }) => (
<div
@ -21,13 +31,25 @@ export default function Table({ columns, rows, empty }) {
</div>
))}
</div>
<div className={styles.body}>
)}
<div className={classNames(styles.body, bodyClassName)}>
{rows.length === 0 && <NoData />}
{rows.map((row, rowIndex) => (
<div className={classNames(styles.row, 'row')} key={rowIndex}>
{columns.map(({ key, render, className, style, cell }) => (
{!children &&
rows.map((row, index) => {
const id = rowKey ? rowKey(row) : index;
return <TableRow key={id} columns={columns} row={row} />;
})}
{children}
</div>
</div>
);
}
export const TableRow = ({ columns, row }) => (
<div className={classNames(styles.row, 'row')}>
{columns.map(({ key, render, className, style, cell }, index) => (
<div
key={`${rowIndex}${key}`}
key={`${key}-${index}`}
className={classNames(styles.cell, className, cell?.className)}
style={{ ...style, ...cell?.style }}
>
@ -35,8 +57,4 @@ export default function Table({ columns, rows, empty }) {
</div>
))}
</div>
))}
</div>
</div>
);
}
);

7
components/common/Tag.js Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Tag.module.css';
export default function Tag({ className, children }) {
return <span className={classNames(styles.tag, className)}>{children}</span>;
}

View File

@ -1,5 +1,4 @@
.type {
font-size: var(--font-size-small);
.tag {
padding: 2px 4px;
border: 1px solid var(--gray300);
border-radius: 4px;

View File

@ -9,7 +9,7 @@
justify-content: space-between;
align-items: center;
padding: 8px 16px;
color: var(--gray50);
color: var(--msgColor);
background: var(--green400);
margin: auto;
z-index: 2;

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

@ -1,22 +0,0 @@
.container {
display: flex;
position: relative;
cursor: pointer;
}
.button {
display: flex;
flex-wrap: nowrap;
}
.username {
border-bottom: 1px solid var(--gray500);
}
.username:hover {
background: var(--gray50);
}
.menu {
z-index: 100;
}

View File

@ -1,44 +1,61 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import ReactTooltip from 'react-tooltip';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import classNames from 'classnames';
import tinycolor from 'tinycolor2';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import useTheme from 'hooks/useTheme';
import { THEME_COLORS } from 'lib/constants';
import styles from './WorldMap.module.css';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
const geoUrl = '/world-110m.json';
export default function WorldMap({
data,
className,
baseColor = '#e9f3fd',
fillColor = '#f5f5f5',
strokeColor = '#2680eb',
hoverColor = '#2680eb',
}) {
export default function WorldMap({ data, className }) {
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 '#ffffff';
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
return country ? tinycolor(baseColor).darken(country.z) : fillColor;
if (!country) {
return colors.fillColor;
}
function getStrokeColor(code) {
return code === 'AQ' ? '#ffffff' : strokeColor;
return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken'](
40 * (1.0 - country.z / 100),
);
}
function getHoverColor(code) {
return code === 'AQ' ? '#ffffff' : hoverColor;
function getOpacity(code) {
return code === 'AQ' ? 0 : 1;
}
function handleHover({ ISO_A2: code, NAME: name }) {
function handleHover(code) {
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
setTooltip(`${name}: ${country?.y || 0} visitors`);
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
}
return (
<div className={classNames(styles.container, className)}>
<ComposableMap data-tip="" projection="geoMercator">
<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={geoUrl}>
{({ geographies }) => {
@ -50,13 +67,14 @@ export default function WorldMap({
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={getStrokeColor(code)}
stroke={colors.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: getHoverColor(code) },
hover: { outline: 'none', fill: colors.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(geo.properties)}
onMouseOver={() => handleHover(code)}
onMouseOut={() => setTooltip(null)}
/>
);
@ -65,7 +83,7 @@ export default function WorldMap({
</Geographies>
</ZoomableGroup>
</ComposableMap>
<ReactTooltip>{tooltip}</ReactTooltip>
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
</div>
);
}

View File

@ -1,5 +1,4 @@
.container {
overflow: hidden;
position: relative;
background: #fff;
}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import { useRouter } from 'next/router';
import { post } from 'lib/web';
import Button from 'components/common/Button';
import FormLayout, {
@ -29,18 +30,17 @@ const validate = ({ user_id, username, password }) => {
};
export default function AccountEditForm({ values, onSave, onClose }) {
const { basePath } = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async values => {
const response = await post(`/api/account`, values);
const { ok, data } = await post(`${basePath}/api/account`, values);
if (typeof response !== 'string') {
if (ok) {
onSave();
} else {
setMessage(
response || (
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
),
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
@ -58,22 +58,26 @@ export default function AccountEditForm({ values, onSave, onClose }) {
<label htmlFor="username">
<FormattedMessage id="label.username" defaultMessage="Username" />
</label>
<div>
<Field name="username" type="text" />
<FormError name="username" />
</div>
</FormRow>
<FormRow>
<label htmlFor="password">
<FormattedMessage id="label.password" defaultMessage="Password" />
</label>
<div>
<Field name="password" type="password" />
<FormError name="password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="button.save" defaultMessage="Save" />
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import { Formik, Form, Field } from 'formik';
import { post } from 'lib/web';
import Button from 'components/common/Button';
@ -37,18 +38,17 @@ const validate = ({ current_password, new_password, confirm_password }) => {
};
export default function ChangePasswordForm({ values, onSave, onClose }) {
const { basePath } = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async values => {
const response = await post(`/api/account/password`, values);
const { ok, data } = await post(`${basePath}/api/account/password`, values);
if (typeof response !== 'string') {
if (ok) {
onSave();
} else {
setMessage(
response || (
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
),
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
@ -66,29 +66,35 @@ export default function ChangePasswordForm({ values, onSave, onClose }) {
<label htmlFor="current_password">
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
</label>
<div>
<Field name="current_password" type="password" />
<FormError name="current_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="new_password">
<FormattedMessage id="label.new-password" defaultMessage="New password" />
</label>
<div>
<Field name="new_password" type="password" />
<FormError name="new_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="confirm_password">
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
</label>
<div>
<Field name="confirm_password" type="password" />
<FormError name="confirm_password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="button.save" defaultMessage="Save" />
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -6,7 +6,7 @@ import Button from 'components/common/Button';
import { FormButtons } from 'components/layout/FormLayout';
import { getDateRangeValues } from 'lib/date';
import styles from './DatePickerForm.module.css';
import ButtonGroup from '../common/ButtonGroup';
import ButtonGroup from 'components/common/ButtonGroup';
const FILTER_DAY = 0;
const FILTER_RANGE = 1;
@ -33,11 +33,11 @@ export default function DatePickerForm({
const buttons = [
{
label: <FormattedMessage id="button.single-day" defaultMessage="Single day" />,
label: <FormattedMessage id="label.single-day" defaultMessage="Single day" />,
value: FILTER_DAY,
},
{
label: <FormattedMessage id="button.date-range" defaultMessage="Date range" />,
label: <FormattedMessage id="label.date-range" defaultMessage="Date range" />,
value: FILTER_RANGE,
},
];
@ -72,10 +72,10 @@ export default function DatePickerForm({
</div>
<FormButtons>
<Button variant="action" onClick={handleSave} disabled={disabled}>
<FormattedMessage id="button.save" defaultMessage="Save" />
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</div>

View File

@ -1,4 +1,6 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import { Formik, Form, Field } from 'formik';
import { del } from 'lib/web';
import Button from 'components/common/Button';
@ -8,7 +10,6 @@ import FormLayout, {
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import { FormattedMessage } from 'react-intl';
const CONFIRMATION_WORD = 'DELETE';
@ -27,15 +28,18 @@ const validate = ({ confirmation }) => {
};
export default function DeleteForm({ values, onSave, onClose }) {
const { basePath } = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async ({ type, id }) => {
const response = await del(`/api/${type}/${id}`);
const { ok, data } = await del(`${basePath}/api/${type}/${id}`);
if (typeof response !== 'string') {
if (ok) {
onSave();
} else {
setMessage(<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />);
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
@ -69,8 +73,10 @@ export default function DeleteForm({ values, onSave, onClose }) {
/>
</p>
<FormRow>
<div>
<Field name="confirmation" type="text" />
<FormError name="confirmation" />
</div>
</FormRow>
<FormButtons>
<Button
@ -78,10 +84,10 @@ export default function DeleteForm({ values, onSave, onClose }) {
variant="danger"
disabled={props.values.confirmation !== CONFIRMATION_WORD}
>
<FormattedMessage id="button.delete" defaultMessage="Delete" />
<FormattedMessage id="label.delete" defaultMessage="Delete" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Router from 'next/router';
import { useRouter } from 'next/router';
import { post } from 'lib/web';
import Button from 'components/common/Button';
import FormLayout, {
@ -28,22 +28,26 @@ const validate = ({ username, password }) => {
};
export default function LoginForm() {
const router = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async ({ username, password }) => {
const response = await post('/api/auth/login', { username, password });
const { ok, status, data } = await post(`${router.basePath}/api/auth/login`, {
username,
password,
});
if (typeof response !== 'string') {
await Router.push('/');
if (ok) {
return router.push('/');
} else {
setMessage(
response.startsWith('401') ? (
status === 401 ? (
<FormattedMessage
id="message.incorrect-username-password"
defaultMessage="Incorrect username/password."
/>
) : (
response
data
),
);
}
@ -67,19 +71,23 @@ export default function LoginForm() {
<label htmlFor="username">
<FormattedMessage id="label.username" defaultMessage="Username" />
</label>
<div>
<Field name="username" type="text" />
<FormError name="username" />
</div>
</FormRow>
<FormRow>
<label htmlFor="password">
<FormattedMessage id="label.password" defaultMessage="Password" />
</label>
<div>
<Field name="password" type="password" />
<FormError name="password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="button.login" defaultMessage="Login" />
<FormattedMessage id="label.login" defaultMessage="Login" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -30,7 +30,7 @@ export default function TrackingCodeForm({ values, onClose }) {
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>

View File

@ -29,7 +29,7 @@ export default function TrackingCodeForm({ values, onClose }) {
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>

View File

@ -11,6 +11,7 @@ import FormLayout, {
} from 'components/layout/FormLayout';
import Checkbox from 'components/common/Checkbox';
import { DOMAIN_REGEX } from 'lib/constants';
import { useRouter } from 'next/router';
const initialValues = {
name: '',
@ -34,15 +35,18 @@ const validate = ({ name, domain }) => {
};
export default function WebsiteEditForm({ values, onSave, onClose }) {
const { basePath } = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async values => {
const response = await post(`/api/website`, values);
const { ok, data } = await post(`${basePath}/api/website`, values);
if (typeof response !== 'string') {
if (ok) {
onSave();
} else {
setMessage(<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />);
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
@ -59,15 +63,19 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
<label htmlFor="name">
<FormattedMessage id="label.name" defaultMessage="Name" />
</label>
<div>
<Field name="name" type="text" />
<FormError name="name" />
</div>
</FormRow>
<FormRow>
<label htmlFor="domain">
<FormattedMessage id="label.domain" defaultMessage="Domain" />
</label>
<div>
<Field name="domain" type="text" />
<FormError name="domain" />
</div>
</FormRow>
<FormRow>
<label></label>
@ -87,10 +95,10 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="button.save" defaultMessage="Save" />
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -2,6 +2,16 @@ import React from 'react';
import classNames from 'classnames';
import styles from './ButtonLayout.module.css';
export default function ButtonLayout({ className, children }) {
return <div className={classNames(styles.buttons, className)}>{children}</div>;
export default function ButtonLayout({ className, children, align = 'center' }) {
return (
<div
className={classNames(styles.buttons, className, {
[styles.left]: align === 'left',
[styles.center]: align === 'center',
[styles.right]: align === 'right',
})}
>
{children}
</div>
);
}

View File

@ -6,3 +6,15 @@
.buttons button + * {
margin-left: 10px;
}
.center {
justify-content: center;
}
.left {
justify-content: flex-start;
}
.right {
justify-content: flex-end;
}

View File

@ -1,15 +1,17 @@
import React from 'react';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import Link from 'components/common/Link';
import styles from './Footer.module.css';
import useVersion from 'hooks/useVersion';
export default function Footer() {
const version = process.env.VERSION;
const { current } = useVersion();
return (
<footer className="container">
<div className={styles.footer}>
<div />
<div>
<div className={classNames(styles.footer, 'row')}>
<div className="col-12 col-md-4" />
<div className="col-12 col-md-4">
<FormattedMessage
id="message.powered-by"
defaultMessage="Powered by {name}"
@ -22,7 +24,9 @@ export default function Footer() {
}}
/>
</div>
<div>{`v${version}`}</div>
<div className={classNames(styles.version, 'col-12 col-md-4')}>
<Link href={`https://github.com/mikecao/umami/releases`}>{`v${current}`}</Link>
</div>
</div>
</footer>
);

View File

@ -4,4 +4,15 @@
align-items: center;
font-size: var(--font-size-small);
min-height: 100px;
text-align: center;
}
.version {
text-align: right;
}
@media only screen and (max-width: 768px) {
.version {
text-align: center;
}
}

View File

@ -17,6 +17,10 @@
line-height: 1.8;
}
.row > div {
position: relative;
}
.buttons {
display: flex;
justify-content: center;
@ -33,13 +37,13 @@
justify-content: center;
align-items: center;
top: 0;
left: 100%;
left: calc(100% + 16px);
bottom: 0;
margin-left: 16px;
z-index: 1;
}
.msg {
color: var(--gray50);
color: var(--msgColor);
background: var(--red400);
font-size: var(--font-size-small);
padding: 4px 8px;
@ -68,3 +72,15 @@
color: var(--gray50);
background: var(--gray800);
}
@media only screen and (max-width: 576px) {
.error {
align-items: flex-start;
top: calc(100% + 7px);
left: 0;
}
.error:after {
left: 10px;
}
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import classNames from 'classnames';
import styles from './GridLayout.module.css';
export default function GridLayout({ className, children }) {
return <div className={classNames(styles.grid, className)}>{children}</div>;
}
export const GridRow = ({ className, children }) => {
return <div className={classNames(styles.row, className, 'row')}>{children}</div>;
};
export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => {
const classes = [];
classes.push(xs ? `col-${xs}` : 'col-12');
if (sm) {
classes.push(`col-sm-${sm}`);
}
if (md) {
classes.push(`col-md-${md}`);
}
if (lg) {
classes.push(`col-lg-${lg}`);
}
if (xl) {
classes.push(`col-lg-${xl}`);
}
return <div className={classNames(styles.col, classes, className)}>{children}</div>;
};

View File

@ -0,0 +1,40 @@
.grid {
display: flex;
flex-direction: column;
}
.col {
display: flex;
flex-direction: column;
}
.row {
border-top: 1px solid var(--gray300);
min-height: 430px;
}
.row > .col {
border-left: 1px solid var(--gray300);
padding: 20px;
}
.row > .col:first-child {
border-left: 0;
padding-left: 0;
}
.row > .col:last-child {
padding-right: 0;
}
@media only screen and (max-width: 992px) {
.row {
border: 0;
}
.row > .col {
border-top: 1px solid var(--gray300);
border-left: 0;
padding: 0;
}
}

View File

@ -3,41 +3,48 @@ import { FormattedMessage } from 'react-intl';
import { useSelector } from 'react-redux';
import classNames from 'classnames';
import Link from 'components/common/Link';
import UserButton from '../common/UserButton';
import Icon from '../common/Icon';
import Icon from 'components/common/Icon';
import LanguageButton from 'components/settings/LanguageButton';
import ThemeButton from 'components/settings/ThemeButton';
import UpdateNotice from 'components/common/UpdateNotice';
import UserButton from 'components/settings/UserButton';
import Logo from 'assets/logo.svg';
import styles from './Header.module.css';
import LanguageButton from '../common/LanguageButton';
export default function Header() {
const user = useSelector(state => state.user);
return (
<header className="container">
{user?.is_admin && <UpdateNotice />}
<div className={classNames(styles.header, 'row align-items-center')}>
<div className="col-12 col-md-3">
<div className="col-12 col-md-12 col-lg-3">
<div className={styles.title}>
<Icon icon={<Logo />} size="large" className={styles.logo} />
<Link href={user ? '/' : 'https://umami.is'}>umami</Link>
</div>
</div>
<div className="col-12 col-md-9">
<div className="col-12 col-md-12 col-lg-6">
{user && (
<div className={styles.nav}>
{user ? (
<>
<Link href="/dashboard">
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
</Link>
<Link href="/realtime">
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</Link>
<Link href="/settings">
<FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link>
<LanguageButton menuAlign="right" />
<UserButton />
</>
) : (
<LanguageButton menuAlign="right" />
</div>
)}
</div>
<div className="col-12 col-md-12 col-lg-3">
<div className={styles.buttons}>
<ThemeButton />
<LanguageButton menuAlign="right" />
{user && <UserButton />}
</div>
</div>
</div>
</header>

View File

@ -5,6 +5,9 @@
.title {
font-size: var(--font-size-large);
display: flex;
align-items: center;
line-height: 1.4;
}
.logo {
@ -13,18 +16,34 @@
.nav {
display: flex;
justify-content: flex-end;
justify-content: center;
align-items: center;
font-size: var(--font-size-normal);
font-weight: 600;
}
.nav > * {
.nav a + a {
margin-left: 40px;
}
@media only screen and (max-width: 768px) {
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
}
@media only screen and (max-width: 992px) {
.title {
text-align: center;
justify-content: center;
}
.nav {
font-size: var(--font-size-large);
justify-content: center;
padding: 20px 0;
}
.buttons {
justify-content: center;
}
}

View File

@ -16,8 +16,8 @@ export default function Layout({ title, children, header = true, footer = true }
</Head>
{header && <Header />}
<main className="container">{children}</main>
<div id="__modals" />
{footer && <Footer />}
<div id="__modals" />
</>
);
}

View File

@ -0,0 +1,6 @@
.layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Page.module.css';
export default function Page({ children }) {
return <div className={styles.page}>{children}</div>;
export default function Page({ className, children }) {
return <div className={classNames(styles.page, className)}>{children}</div>;
}

View File

@ -1,6 +1,7 @@
.page {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 30px;
background: var(--gray50);
height: 100%;
overflow: hidden;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import styles from './PageHeader.module.css';
export default function PageHeader({ children }) {
return <div className={styles.header}>{children}</div>;
export default function PageHeader({ children, className }) {
return <div className={classNames(styles.header, className)}>{children}</div>;
}

View File

@ -4,4 +4,5 @@
align-items: center;
align-content: center;
min-height: 80px;
align-self: stretch;
}

View File

@ -1,11 +1,18 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import useFetch from 'hooks/useFetch';
import Dot from 'components/common/Dot';
import { TOKEN_HEADER } from 'lib/constants';
import useShareToken from 'hooks/useShareToken';
import styles from './ActiveUsers.module.css';
import { FormattedMessage } from 'react-intl';
export default function ActiveUsers({ websiteId, token, className }) {
const { data } = useFetch(`/api/website/${websiteId}/active`, { token }, { interval: 60000 });
export default function ActiveUsers({ websiteId, className }) {
const shareToken = useShareToken();
const { data } = useFetch(`/api/website/${websiteId}/active`, {
interval: 60000,
headers: { [TOKEN_HEADER]: shareToken?.token },
});
const count = useMemo(() => {
return data?.[0]?.x || 0;
}, [data]);
@ -16,7 +23,7 @@ export default function ActiveUsers({ websiteId, token, className }) {
return (
<div className={classNames(styles.container, className)}>
<div className={styles.dot} />
<Dot />
<div className={styles.text}>
<div>
<FormattedMessage

View File

@ -12,11 +12,3 @@
font-weight: 600;
margin-right: 4px;
}
.dot {
background: var(--green400);
width: 10px;
height: 10px;
border-radius: 100%;
margin-right: 10px;
}

View File

@ -1,34 +1,50 @@
import React, { useState, useRef, useEffect } from 'react';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import ChartJS from 'chart.js';
import Legend from 'components/metrics/Legend';
import { formatLongNumber } from 'lib/format';
import { dateFormat } from 'lib/lang';
import useLocale from 'hooks/useLocale';
import useTheme from 'hooks/useTheme';
import { DEFAUL_CHART_HEIGHT, DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
import styles from './BarChart.module.css';
import ChartTooltip from './ChartTooltip';
import useForceUpdate from '../../hooks/useForceUpdate';
export default function BarChart({
chartId,
datasets,
unit,
records,
height = 400,
animationDuration = 300,
height = DEFAUL_CHART_HEIGHT,
animationDuration = DEFAULT_ANIMATION_DURATION,
className,
stacked = false,
loading = false,
onCreate = () => {},
onUpdate = () => {},
}) {
const canvas = useRef();
const chart = useRef();
const [tooltip, setTooltip] = useState({});
const [tooltip, setTooltip] = useState(null);
const [locale] = useLocale();
const [theme] = useTheme();
const forceUpdate = useForceUpdate();
const colors = {
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
zeroLine: THEME_COLORS[theme].gray500,
};
function renderXLabel(label, index, values) {
if (loading) return '';
const d = new Date(values[index].value);
const w = canvas.current.width;
switch (unit) {
case 'minute':
return index % 2 === 0 ? dateFormat(d, 'h:mm', locale) : '';
case 'hour':
return dateFormat(d, 'ha', locale);
case 'day':
@ -53,15 +69,17 @@ export default function BarChart({
}
function renderYLabel(label) {
return +label > 1 ? formatLongNumber(label) : label;
return +label > 1000 ? formatLongNumber(label) : label;
}
function renderTooltip(model) {
const { opacity, title, body, labelColors } = model;
if (!opacity) {
if (!opacity || !title) {
setTooltip(null);
} else {
return;
}
const [label, value] = body[0].lines[0].split(':');
setTooltip({
@ -71,7 +89,6 @@ export default function BarChart({
labelColor: labelColors[0].backgroundColor,
});
}
}
function getTooltipFormat(unit) {
switch (unit) {
@ -97,6 +114,9 @@ export default function BarChart({
responsive: true,
responsiveAnimationDuration: 0,
maintainAspectRatio: false,
legend: {
display: false,
},
scales: {
xAxes: [
{
@ -110,6 +130,7 @@ export default function BarChart({
callback: renderXLabel,
minRotation: 0,
maxRotation: 0,
fontColor: colors.text,
},
gridLines: {
display: false,
@ -123,6 +144,11 @@ export default function BarChart({
ticks: {
callback: renderYLabel,
beginAtZero: true,
fontColor: colors.text,
},
gridLines: {
color: colors.line,
zeroLineColor: colors.zeroLine,
},
stacked,
},
@ -144,12 +170,21 @@ export default function BarChart({
function updateChart() {
const { options } = chart.current;
options.legend.labels.fontColor = colors.text;
options.scales.xAxes[0].time.unit = unit;
options.scales.xAxes[0].ticks.callback = renderXLabel;
options.scales.xAxes[0].ticks.fontColor = colors.text;
options.scales.yAxes[0].ticks.fontColor = colors.text;
options.scales.yAxes[0].gridLines.color = colors.line;
options.scales.yAxes[0].gridLines.zeroLineColor = colors.zeroLine;
options.animation.duration = animationDuration;
options.tooltips.custom = renderTooltip;
onUpdate(chart.current);
chart.current.update();
forceUpdate();
}
useEffect(() => {
@ -161,7 +196,7 @@ export default function BarChart({
updateChart();
}
}
}, [datasets, unit, animationDuration, locale]);
}, [datasets, unit, animationDuration, locale, theme]);
return (
<>
@ -173,23 +208,8 @@ export default function BarChart({
>
<canvas ref={canvas} />
</div>
<ReactTooltip id={`${chartId}-tooltip`}>
{tooltip ? <Tooltip {...tooltip} /> : null}
</ReactTooltip>
<Legend chart={chart.current} />
<ChartTooltip chartId={chartId} tooltip={tooltip} />
</>
);
}
const Tooltip = ({ title, value, label, labelColor }) => (
<div className={styles.tooltip}>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<div className={styles.dot}>
<div className={styles.color} style={{ backgroundColor: labelColor }} />
</div>
{value} {label}
</div>
</div>
</div>
);

View File

@ -1,43 +1,3 @@
.chart {
position: relative;
}
.tooltip {
pointer-events: none;
z-index: 1;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--gray50);
text-align: center;
}
.title {
font-size: var(--font-size-xsmall);
font-weight: 600;
}
.metric {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-small);
font-weight: 600;
}
.dot {
position: relative;
overflow: hidden;
border-radius: 100%;
margin-right: 8px;
background: var(--gray50);
}
.color {
width: 10px;
height: 10px;
}

View File

@ -3,17 +3,15 @@ import { FormattedMessage } from 'react-intl';
import MetricsTable from './MetricsTable';
import { browserFilter } from 'lib/filters';
export default function BrowsersTable({ websiteId, token, limit, onExpand }) {
export default function BrowsersTable({ websiteId, ...props }) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />}
type="browser"
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
websiteId={websiteId}
token={token}
limit={limit}
dataFilter={browserFilter}
onExpand={onExpand}
/>
);
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import Dot from 'components/common/Dot';
import styles from './ChartTooltip.module.css';
import ReactTooltip from 'react-tooltip';
export default function ChartTooltip({ chartId, tooltip }) {
if (!tooltip) {
return null;
}
const { title, value, label, labelColor } = tooltip;
return (
<ReactTooltip id={`${chartId}-tooltip`}>
<div className={styles.tooltip}>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<Dot color={labelColor} />
{value} {label}
</div>
</div>
</div>
</ReactTooltip>
);
}

View File

@ -0,0 +1,43 @@
.chart {
position: relative;
}
.tooltip {
color: var(--msgColor);
pointer-events: none;
z-index: 1;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.title {
font-size: var(--font-size-xsmall);
font-weight: 600;
}
.metric {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-small);
font-weight: 600;
}
.dot {
position: relative;
overflow: hidden;
border-radius: 100%;
margin-right: 8px;
background: var(--gray50);
}
.color {
width: 10px;
height: 10px;
}

View File

@ -1,26 +1,27 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { countryFilter, percentFilter } from 'lib/filters';
import { percentFilter } from 'lib/filters';
import { FormattedMessage } from 'react-intl';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
const [locale] = useLocale();
const countryNames = useCountryNames(locale);
function renderLabel({ x }) {
return <div className={locale}>{countryNames[x]}</div>;
}
export default function CountriesTable({
websiteId,
token,
limit,
onDataLoad = () => {},
onExpand,
}) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
type="country"
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
websiteId={websiteId}
token={token}
limit={limit}
dataFilter={countryFilter}
onDataLoad={data => onDataLoad(percentFilter(data))}
onExpand={onExpand}
onDataLoad={data => onDataLoad?.(percentFilter(data))}
renderLabel={renderLabel}
/>
);
}

View File

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import { formatNumber, formatLongNumber } from 'lib/format';
import styles from './DataTable.module.css';
export default function DataTable({
data,
title,
metric,
className,
renderLabel,
height,
animate = true,
virtualize = false,
}) {
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const handleSetFormat = () => setFormat(state => !state);
const getRow = row => {
const { x: label, y: value, z: percent } = row;
return (
<AnimatedRow
key={label}
label={renderLabel ? renderLabel(row) : label}
value={value}
percent={percent}
animate={animate && !virtualize}
format={formatFunc}
onClick={handleSetFormat}
/>
);
};
const Row = ({ index, style }) => {
return <div style={style}>{getRow(data[index])}</div>;
};
return (
<div className={classNames(styles.table, className)}>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.metric} onClick={handleSetFormat}>
{metric}
</div>
</div>
<div className={styles.body} style={{ height }}>
{data?.length === 0 && <NoData />}
{virtualize && data.length > 0 ? (
<FixedSizeList height={height} itemCount={data.length} itemSize={30}>
{Row}
</FixedSizeList>
) : (
data.map(row => getRow(row))
)}
</div>
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<div className={styles.value} onClick={onClick}>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
</div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View File

@ -0,0 +1,95 @@
.table {
position: relative;
font-size: var(--font-size-small);
display: flex;
flex-direction: column;
flex: 1;
}
.body {
position: relative;
overflow: auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
}
.title {
display: flex;
font-weight: 600;
font-size: var(--font-size-normal);
}
.metric {
font-size: var(--font-size-small);
font-weight: 600;
text-align: center;
width: 100px;
cursor: pointer;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
overflow: hidden;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 2;
}
.label a {
color: inherit;
text-decoration: none;
}
.label a:hover {
color: var(--primary400);
}
.label:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
cursor: pointer;
}
.percent {
position: relative;
width: 50px;
color: var(--gray600);
border-left: 1px solid var(--gray600);
padding-left: 10px;
z-index: 1;
}
.bar {
position: absolute;
top: 0;
left: 0;
height: 30px;
opacity: 0.1;
background: var(--primary400);
z-index: -1;
}

View File

@ -4,18 +4,16 @@ import { deviceFilter } from 'lib/filters';
import { FormattedMessage } from 'react-intl';
import { getDeviceMessage } from 'components/messages';
export default function DevicesTable({ websiteId, token, limit, onExpand }) {
export default function DevicesTable({ websiteId, ...props }) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.devices" defaultMessage="Devices" />}
type="device"
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
websiteId={websiteId}
token={token}
limit={limit}
dataFilter={deviceFilter}
renderLabel={({ x }) => getDeviceMessage(x)}
onExpand={onExpand}
/>
);
}

View File

@ -1,37 +1,40 @@
import React, { useMemo } from 'react';
import tinycolor from 'tinycolor2';
import BarChart from './BarChart';
import { getTimezone, getDateArray, getDateLength } from 'lib/date';
import { getDateArray, getDateLength } from 'lib/date';
import useFetch from 'hooks/useFetch';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import { EVENT_COLORS, TOKEN_HEADER } from 'lib/constants';
const COLORS = [
'#2680eb',
'#9256d9',
'#44b556',
'#e68619',
'#e34850',
'#1b959a',
'#d83790',
'#85d044',
];
export default function EventsChart({ websiteId, token }) {
const dateRange = useDateRange(websiteId);
export default function EventsChart({ websiteId, className, token }) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, modified } = dateRange;
const { data } = useFetch(
const [timezone] = useTimezone();
const { query } = usePageQuery();
const shareToken = useShareToken();
const { data, loading } = useFetch(
`/api/website/${websiteId}/events`,
{
params: {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
tz: timezone,
url: query.url,
token,
},
{ update: [modified] },
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified],
);
const datasets = useMemo(() => {
if (!data) return [];
if (loading) return data;
const map = data.reduce((obj, { x, t, y }) => {
if (!obj[x]) {
@ -48,25 +51,17 @@ export default function EventsChart({ websiteId, token }) {
});
return Object.keys(map).map((key, index) => {
const color = tinycolor(COLORS[index]);
const color = tinycolor(EVENT_COLORS[index % EVENT_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.setAlpha(0.4).toRgbString(),
borderColor: color.setAlpha(0.5).toRgbString(),
backgroundColor: color.setAlpha(0.6).toRgbString(),
borderColor: color.setAlpha(0.7).toRgbString(),
borderWidth: 1,
};
});
}, [data]);
function handleCreate(options) {
const legend = {
position: 'bottom',
};
options.legend = legend;
}
}, [data, loading]);
function handleUpdate(chart) {
chart.data.datasets = datasets;
@ -81,11 +76,13 @@ export default function EventsChart({ websiteId, token }) {
return (
<BarChart
chartId={`events-${websiteId}`}
className={className}
datasets={datasets}
unit={unit}
height={300}
records={getDateLength(startDate, endDate, unit)}
onCreate={handleCreate}
onUpdate={handleUpdate}
loading={loading}
stacked
/>
);

View File

@ -1,20 +1,17 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import MetricsTable from './MetricsTable';
import styles from './EventsTable.module.css';
import Tag from 'components/common/Tag';
export default function EventsTable({ websiteId, token, limit, onExpand, onDataLoad }) {
export default function EventsTable({ websiteId, ...props }) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
type="event"
metric={<FormattedMessage id="metrics.actions" defaultMessage="Actions" />}
websiteId={websiteId}
token={token}
limit={limit}
renderLabel={({ x }) => <Label value={x} />}
onExpand={onExpand}
onDataLoad={onDataLoad}
/>
);
}
@ -23,7 +20,7 @@ const Label = ({ value }) => {
const [event, label] = value.split(':');
return (
<>
<span className={styles.type}>{event}</span>
<Tag>{event}</Tag>
{label}
</>
);

View File

@ -0,0 +1,40 @@
import React from 'react';
import classNames from 'classnames';
import Dot from 'components/common/Dot';
import useLocale from 'hooks/useLocale';
import styles from './Legend.module.css';
import useForceUpdate from '../../hooks/useForceUpdate';
export default function Legend({ chart }) {
const [locale] = useLocale();
const forceUpdate = useForceUpdate();
function handleClick(index) {
const meta = chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null;
chart.update();
forceUpdate();
}
if (!chart?.legend?.legendItems.find(({ text }) => text)) {
return null;
}
return (
<div className={styles.legend}>
{chart.legend.legendItems.map(({ text, fillStyle, datasetIndex, hidden }) => (
<div
key={text}
className={classNames(styles.label, { [styles.hidden]: hidden })}
onClick={() => handleClick(datasetIndex)}
>
<Dot color={fillStyle} />
<span className={locale}>{text}</span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,21 @@
.legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 10px;
}
.label {
display: flex;
align-items: center;
font-size: var(--font-size-xsmall);
cursor: pointer;
}
.label + .label {
margin-left: 20px;
}
.hidden {
color: var(--gray400);
}

View File

@ -3,7 +3,6 @@
flex-direction: column;
justify-content: center;
min-width: 140px;
padding-right: 20px;
}
.value {

View File

@ -2,27 +2,37 @@ import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Loading from 'components/common/Loading';
import ErrorMessage from 'components/common/ErrorMessage';
import useFetch from 'hooks/useFetch';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import { TOKEN_HEADER } from 'lib/constants';
import MetricCard from './MetricCard';
import styles from './MetricsBar.module.css';
export default function MetricsBar({ websiteId, token, className }) {
const dateRange = useDateRange(websiteId);
export default function MetricsBar({ websiteId, className }) {
const shareToken = useShareToken();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/metrics`,
const [format, setFormat] = useState(true);
const {
query: { url },
} = usePageQuery();
const { data, error, loading } = useFetch(
`/api/website/${websiteId}/stats`,
{
params: {
start_at: +startDate,
end_at: +endDate,
token,
url,
},
{
update: [modified],
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[url, modified],
);
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
@ -34,9 +44,9 @@ export default function MetricsBar({ websiteId, token, className }) {
return (
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
{!data ? (
<Loading />
) : (
{!data && loading && <Loading />}
{error && <ErrorMessage />}
{data && !error && (
<>
<MetricCard
label={<FormattedMessage id="metrics.views" defaultMessage="Views" />}

View File

@ -1,13 +1,18 @@
.bar {
display: flex;
cursor: pointer;
min-height: 80px;
}
.bar > div + div {
padding-left: 20px;
}
@media only screen and (max-width: 992px) {
.bar {
justify-content: space-between;
}
.bar > div:last-child {
.bar > div:nth-child(n + 3) {
display: none;
}
}

View File

@ -1,143 +1,86 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import firstBy from 'thenby';
import classNames from 'classnames';
import Button from 'components/common/Button';
import Link from 'components/common/Link';
import Loading from 'components/common/Loading';
import NoData from 'components/common/NoData';
import useFetch from 'hooks/useFetch';
import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import ErrorMessage from 'components/common/ErrorMessage';
import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION, TOKEN_HEADER } from 'lib/constants';
import styles from './MetricsTable.module.css';
export default function MetricsTable({
websiteId,
websiteDomain,
token,
title,
metric,
type,
className,
dataFilter,
filterOptions,
limit,
headerComponent,
renderLabel,
onDataLoad = () => {},
onExpand = () => {},
onDataLoad,
...props
}) {
const dateRange = useDateRange(websiteId);
const shareToken = useShareToken();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data } = useFetch(
`/api/website/${websiteId}/rankings`,
const {
resolve,
router,
query: { url },
} = usePageQuery();
const { data, loading, error } = useFetch(
`/api/website/${websiteId}/metrics`,
{
params: {
type,
start_at: +startDate,
end_at: +endDate,
domain: websiteDomain,
token,
url,
},
{ onDataLoad, delay: 300, update: [modified] },
onDataLoad,
delay: DEFAULT_ANIMATION_DURATION,
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified],
);
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0;
const rankings = useMemo(() => {
const filteredData = useMemo(() => {
if (data) {
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
if (limit) {
return items.filter((e, i) => i < limit);
return items.filter((e, i) => i < limit).sort(firstBy('y', -1).thenBy('x'));
}
return items;
return items.sort(firstBy('y', -1).thenBy('x'));
}
return [];
}, [data, dataFilter, filterOptions]);
const handleSetFormat = () => setFormat(state => !state);
const getRow = row => {
const { x: label, y: value, z: percent } = row;
return (
<AnimatedRow
key={label}
label={renderLabel ? renderLabel(row) : label}
value={value}
percent={percent}
animate={shouldAnimate}
format={formatFunc}
onClick={handleSetFormat}
/>
);
};
const Row = ({ index, style }) => {
return <div style={style}>{getRow(rankings[index])}</div>;
};
}, [data, error, dataFilter, filterOptions]);
return (
<div className={classNames(styles.container, className)}>
{!data && <Loading />}
{data && (
<>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
{headerComponent}
<div className={styles.metric} onClick={handleSetFormat}>
{metric}
</div>
</div>
<div className={styles.body}>
{rankings?.length === 0 && <NoData />}
{limit
? rankings.map(row => getRow(row))
: rankings.length > 0 && (
<FixedSizeList height={500} itemCount={rankings.length} itemSize={30}>
{Row}
</FixedSizeList>
)}
</div>
{!data && loading && <Loading />}
{error && <ErrorMessage />}
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
<div className={styles.footer}>
{limit && data.length > limit && (
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
<div>
<FormattedMessage id="button.more" defaultMessage="More" />
</div>
</Button>
{data && !error && limit && (
<Link
icon={<Arrow />}
href={router.pathname}
as={resolve({ view: type })}
size="small"
iconRight
>
<FormattedMessage id="label.more" defaultMessage="More" />
</Link>
)}
</div>
</>
)}
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<div className={styles.value} onClick={onClick}>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
</div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View File

@ -1,100 +1,11 @@
.container {
position: relative;
min-height: 460px;
min-height: 430px;
font-size: var(--font-size-small);
padding: 20px 0;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
}
.title {
display: flex;
font-weight: 600;
font-size: var(--font-size-normal);
}
.metric {
font-size: var(--font-size-small);
text-align: center;
width: 100px;
cursor: pointer;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
overflow: hidden;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 2;
}
.label a {
color: inherit;
text-decoration: none;
}
.label a:hover {
color: var(--primary400);
}
.label:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
cursor: pointer;
}
.percent {
position: relative;
width: 50px;
color: #6e6e6e;
border-left: 1px solid var(--gray600);
padding-left: 10px;
z-index: 1;
}
.bar {
position: absolute;
top: 0;
left: 0;
height: 30px;
opacity: 0.1;
background: var(--primary400);
z-index: -1;
}
.body {
position: relative;
flex: 1;
overflow: hidden;
}
.footer {
display: flex;
justify-content: center;

View File

@ -3,17 +3,15 @@ import MetricsTable from './MetricsTable';
import { osFilter } from 'lib/filters';
import { FormattedMessage } from 'react-intl';
export default function OSTable({ websiteId, token, limit, onExpand }) {
export default function OSTable({ websiteId, ...props }) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
type="os"
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
websiteId={websiteId}
token={token}
limit={limit}
dataFilter={osFilter}
onExpand={onExpand}
/>
);
}

View File

@ -1,12 +1,22 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import ButtonGroup from 'components/common/ButtonGroup';
import classNames from 'classnames';
import Link from 'next/link';
import FilterButtons from 'components/common/FilterButtons';
import { urlFilter } from 'lib/filters';
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
import usePageQuery from 'hooks/usePageQuery';
import MetricsTable from './MetricsTable';
import styles from './PagesTable.module.css';
export default function PagesTable({ websiteId, token, websiteDomain, limit, onExpand }) {
export const FILTER_COMBINED = 0;
export const FILTER_RAW = 1;
export default function PagesTable({ websiteId, websiteDomain, showFilters, ...props }) {
const [filter, setFilter] = useState(FILTER_COMBINED);
const {
resolve,
query: { url },
} = usePageQuery();
const buttons = [
{
@ -16,25 +26,34 @@ export default function PagesTable({ websiteId, token, websiteDomain, limit, onE
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
];
const renderLink = ({ x }) => {
return (
<Link href={resolve({ url: x })} replace={true}>
<a
className={classNames({
[styles.inactive]: url && x !== url,
[styles.active]: x === url,
})}
>
{decodeURI(x)}
</a>
</Link>
);
};
return (
<>
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
<MetricsTable
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
type="url"
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
headerComponent={
limit ? null : <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
}
websiteId={websiteId}
token={token}
limit={limit}
dataFilter={urlFilter}
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
renderLabel={({ x }) => decodeURI(x)}
onExpand={onExpand}
renderLabel={renderLink}
{...props}
/>
</>
);
}
const FilterButtons = ({ buttons, selected, onClick }) => {
return <ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />;
};

View File

@ -0,0 +1,8 @@
body .inactive {
color: var(--gray500);
}
body .active {
color: var(--gray900);
font-weight: 600;
}

View File

@ -1,17 +1,41 @@
import React from 'react';
import { useIntl } from 'react-intl';
import tinycolor from 'tinycolor2';
import CheckVisible from 'components/helpers/CheckVisible';
import BarChart from './BarChart';
import useTheme from 'hooks/useTheme';
import { THEME_COLORS, DEFAULT_ANIMATION_DURATION } from 'lib/constants';
export default function PageviewsChart({ websiteId, data, unit, records, className }) {
export default function PageviewsChart({
websiteId,
data,
unit,
records,
className,
loading,
animationDuration = DEFAULT_ANIMATION_DURATION,
...props
}) {
const intl = useIntl();
const [theme] = useTheme();
const primaryColor = tinycolor(THEME_COLORS[theme].primary);
const colors = {
views: {
background: primaryColor.setAlpha(0.4).toRgbString(),
border: primaryColor.setAlpha(0.5).toRgbString(),
},
visitors: {
background: primaryColor.setAlpha(0.6).toRgbString(),
border: primaryColor.setAlpha(0.7).toRgbString(),
},
};
const handleUpdate = chart => {
const {
data: { datasets },
} = chart;
datasets[0].data = data.uniques;
datasets[0].data = data.sessions;
datasets[0].label = intl.formatMessage({
id: 'metrics.unique-visitors',
defaultMessage: 'Unique visitors',
@ -21,8 +45,6 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
id: 'metrics.page-views',
defaultMessage: 'Page views',
});
chart.update();
};
if (!data) {
@ -33,6 +55,7 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
<CheckVisible>
{visible => (
<BarChart
{...props}
className={className}
chartId={websiteId}
datasets={[
@ -41,10 +64,10 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
id: 'metrics.unique-visitors',
defaultMessage: 'Unique visitors',
}),
data: data.uniques,
data: data.sessions,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.4)',
borderColor: 'rgb(13, 102, 208, 0.4)',
backgroundColor: colors.visitors.background,
borderColor: colors.visitors.border,
borderWidth: 1,
},
{
@ -54,15 +77,16 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
}),
data: data.pageviews,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.2)',
borderColor: 'rgb(13, 102, 208, 0.2)',
backgroundColor: colors.views.background,
borderColor: colors.views.border,
borderWidth: 1,
},
]}
unit={unit}
records={records}
animationDuration={visible ? 300 : 0}
animationDuration={visible ? animationDuration : 0}
onUpdate={handleUpdate}
loading={loading}
/>
)}
</CheckVisible>

View File

@ -0,0 +1,60 @@
import React, { useMemo, useRef } from 'react';
import { format, parseISO, startOfMinute, subMinutes, isBefore } from 'date-fns';
import PageviewsChart from './PageviewsChart';
import { getDateArray } from 'lib/date';
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from 'lib/constants';
function mapData(data) {
let last = 0;
const arr = [];
data.reduce((obj, val) => {
const { created_at } = val;
const t = startOfMinute(parseISO(created_at));
if (t.getTime() > last) {
obj = { t: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
arr.push(obj);
last = t;
} else {
obj.y += 1;
}
return obj;
}, {});
return arr;
}
export default function RealtimeChart({ data, unit, ...props }) {
const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, REALTIME_RANGE);
const prevEndDate = useRef(endDate);
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
sessions: getDateArray(mapData(data.sessions), startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data]);
// Don't animate the bars shifting over because it looks weird
const animationDuration = useMemo(() => {
if (isBefore(prevEndDate.current, endDate)) {
prevEndDate.current = endDate;
return 0;
}
return DEFAULT_ANIMATION_DURATION;
}, [data]);
return (
<PageviewsChart
{...props}
height={200}
unit={unit}
data={chartData}
animationDuration={animationDuration}
/>
);
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PageHeader from '../layout/PageHeader';
import DropDown from '../common/DropDown';
import MetricCard from './MetricCard';
import styles from './RealtimeHeader.module.css';
export default function RealtimeHeader({ websites, data, websiteId, onSelect }) {
const options = [
{ label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
].concat(
websites.map(({ name, website_id }, index) => ({
label: name,
value: website_id,
divider: index === 0,
})),
);
const { pageviews, sessions, events, countries } = data;
return (
<>
<PageHeader>
<div>
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</div>
<DropDown value={websiteId} options={options} onChange={onSelect} />
</PageHeader>
<div className={styles.metrics}>
<MetricCard
label={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
value={pageviews.length}
/>
<MetricCard
label={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
value={sessions.length}
/>
<MetricCard
label={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
value={events.length}
/>
<MetricCard
label={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
value={countries.length}
/>
</div>
</>
);
}

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