4
.gitignore
vendored
@ -16,13 +16,15 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/public/umami.js
|
/public/umami.js
|
||||||
|
/public/geo
|
||||||
/lang-compiled
|
/lang-compiled
|
||||||
/lang-formatted
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
|
*.log
|
||||||
|
.vscode/*
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
40
Dockerfile
@ -1,15 +1,41 @@
|
|||||||
FROM node:12.18-alpine
|
# Build image
|
||||||
|
FROM node:12.18-alpine AS build
|
||||||
ARG DATABASE_TYPE
|
ARG DATABASE_TYPE
|
||||||
|
|
||||||
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
|
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
|
||||||
DATABASE_TYPE=$DATABASE_TYPE
|
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
|
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
|
EXPOSE 3000
|
||||||
|
CMD ["yarn", "start"]
|
||||||
CMD ["npm", "start"]
|
|
||||||
|
1
assets/bolt.svg
Normal 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 |
1
assets/exclamation-triangle.svg
Normal 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
@ -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
@ -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 |
@ -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
@ -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
@ -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
@ -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 |
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -13,7 +13,8 @@ export default function Button({
|
|||||||
className,
|
className,
|
||||||
tooltip,
|
tooltip,
|
||||||
tooltipId,
|
tooltipId,
|
||||||
disabled = false,
|
disabled,
|
||||||
|
iconRight,
|
||||||
onClick = () => {},
|
onClick = () => {},
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
@ -30,14 +31,14 @@ export default function Button({
|
|||||||
[styles.action]: variant === 'action',
|
[styles.action]: variant === 'action',
|
||||||
[styles.danger]: variant === 'danger',
|
[styles.danger]: variant === 'danger',
|
||||||
[styles.light]: variant === 'light',
|
[styles.light]: variant === 'light',
|
||||||
[styles.disabled]: disabled,
|
[styles.iconRight]: iconRight,
|
||||||
})}
|
})}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={!disabled ? onClick : null}
|
onClick={!disabled ? onClick : null}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon && <Icon icon={icon} size={size} />}
|
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||||
{children}
|
{children && <div className={styles.label}>{children}</div>}
|
||||||
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -3,13 +3,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: var(--font-size-normal);
|
font-size: var(--font-size-normal);
|
||||||
|
color: var(--gray900);
|
||||||
background: var(--gray100);
|
background: var(--gray100);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,17 +18,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button:active {
|
.button:active {
|
||||||
color: initial;
|
color: var(--gray900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.large {
|
.large {
|
||||||
font-size: var(--font-size-large);
|
font-size: var(--font-size-large);
|
||||||
}
|
}
|
||||||
|
|
||||||
.medium {
|
|
||||||
font-size: var(--font-size-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
@ -37,7 +40,8 @@
|
|||||||
font-size: var(--font-size-xsmall);
|
font-size: var(--font-size-xsmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
.action,
|
||||||
|
.action:active {
|
||||||
color: var(--gray50);
|
color: var(--gray50);
|
||||||
background: var(--gray900);
|
background: var(--gray900);
|
||||||
}
|
}
|
||||||
@ -46,7 +50,8 @@
|
|||||||
background: var(--gray800);
|
background: var(--gray800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger {
|
.danger,
|
||||||
|
.danger:active {
|
||||||
color: var(--gray50);
|
color: var(--gray50);
|
||||||
background: var(--red500);
|
background: var(--red500);
|
||||||
}
|
}
|
||||||
@ -55,12 +60,27 @@
|
|||||||
background: var(--red400);
|
background: var(--red400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light {
|
.light,
|
||||||
background: var(--gray50);
|
.light:active {
|
||||||
|
color: var(--gray900);
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light:hover {
|
.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 {
|
.button:disabled {
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
.group .button {
|
.group .button {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
color: var(--gray800);
|
||||||
background: var(--gray50);
|
background: var(--gray50);
|
||||||
border-left: 1px solid var(--gray500);
|
border-left: 1px solid var(--gray500);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -24,6 +25,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.group .button.selected {
|
||||||
|
color: var(--gray900);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: center;
|
vertical-align: center;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
min-width: 40px;
|
width: 40px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
@ -103,3 +103,9 @@
|
|||||||
.icon {
|
.icon {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 992px) {
|
||||||
|
.calendar table {
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import Button from './Button';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const defaultText = (
|
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 }) {
|
export default function CopyButton({ element, ...props }) {
|
||||||
|
17
components/common/Dot.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
22
components/common/Dot.module.css
Normal 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;
|
||||||
|
}
|
@ -15,6 +15,7 @@ export default function DropDown({
|
|||||||
}) {
|
}) {
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
|
const selectedOption = options.find(e => e.value === value);
|
||||||
|
|
||||||
function handleShowMenu() {
|
function handleShowMenu() {
|
||||||
setShowMenu(state => !state);
|
setShowMenu(state => !state);
|
||||||
@ -36,11 +37,17 @@ export default function DropDown({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
||||||
<div className={styles.value}>
|
<div className={styles.value}>
|
||||||
{options.find(e => e.value === value)?.label || value}
|
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div>
|
||||||
<Icon icon={<Chevron />} className={styles.icon} size="small" />
|
<Icon icon={<Chevron />} className={styles.icon} size="small" />
|
||||||
</div>
|
</div>
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<Menu className={menuClassName} options={options} onSelect={handleSelect} float="bottom" />
|
<Menu
|
||||||
|
className={menuClassName}
|
||||||
|
options={options}
|
||||||
|
selectedOption={selectedOption}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
float="bottom"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -19,6 +19,10 @@
|
|||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.text {
|
||||||
padding-left: 10px;
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import styles from './EmptyPlaceholder.module.css';
|
|||||||
export default function EmptyPlaceholder({ msg, children }) {
|
export default function EmptyPlaceholder({ msg, children }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
<Icon icon={<Logo />} size="xlarge" />
|
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
|
||||||
<h2>{msg}</h2>
|
<h2>{msg}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,3 +5,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
14
components/common/ErrorMessage.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
13
components/common/ErrorMessage.module.css
Normal 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;
|
||||||
|
}
|
21
components/common/Favicon.js
Normal 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;
|
||||||
|
}
|
3
components/common/Favicon.module.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.favicon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
11
components/common/FilterButtons.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -5,10 +5,6 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon + * {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon svg {
|
.icon svg {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
@ -1,12 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import NextLink from 'next/link';
|
import NextLink from 'next/link';
|
||||||
|
import Icon from './Icon';
|
||||||
import styles from './Link.module.css';
|
import styles from './Link.module.css';
|
||||||
|
|
||||||
export default function Link({ className, children, ...props }) {
|
export default function Link({ className, icon, children, size, iconRight, ...props }) {
|
||||||
return (
|
return (
|
||||||
<NextLink {...props}>
|
<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>
|
</NextLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,10 @@ a.link,
|
|||||||
a.link:active,
|
a.link:active,
|
||||||
a.link:visited {
|
a.link:visited {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: #2c2c2c;
|
color: var(--gray900);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.link:before {
|
a.link:before {
|
||||||
@ -12,7 +14,7 @@ a.link:before {
|
|||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #2680eb;
|
background: var(--primary400);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: width 100ms;
|
transition: width 100ms;
|
||||||
}
|
}
|
||||||
@ -21,3 +23,28 @@ a.link:hover:before {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
transition: width 100ms;
|
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;
|
||||||
|
}
|
||||||
|
@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
@ -33,7 +33,8 @@ export default function Menu({
|
|||||||
<div
|
<div
|
||||||
key={value}
|
key={value}
|
||||||
className={classNames(styles.option, optionClassName, customClassName, {
|
className={classNames(styles.option, optionClassName, customClassName, {
|
||||||
[selectedClassName]: selectedOption === value,
|
[selectedClassName]: selectedOption === option,
|
||||||
|
[styles.selected]: selectedOption === option,
|
||||||
[styles.divider]: divider,
|
[styles.divider]: divider,
|
||||||
})}
|
})}
|
||||||
onClick={e => onSelect(value, e)}
|
onClick={e => onSelect(value, e)}
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
.menu {
|
.menu {
|
||||||
|
background: var(--gray50);
|
||||||
border: 1px solid var(--gray500);
|
border: 1px solid var(--gray500);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 2;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
background: #fff;
|
background: var(--gray50);
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:hover {
|
.option:hover {
|
||||||
background: #f5f5f5;
|
background: var(--gray100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.float {
|
.float {
|
||||||
@ -44,3 +45,7 @@
|
|||||||
.divider {
|
.divider {
|
||||||
border-top: 1px solid var(--gray300);
|
border-top: 1px solid var(--gray300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
60
components/common/MenuButton.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
20
components/common/MenuButton.module.css
Normal 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);
|
||||||
|
}
|
@ -16,8 +16,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background: var(--gray900);
|
background: #000;
|
||||||
opacity: 0.1;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
.menu {
|
.menu {
|
||||||
|
color: var(--gray800);
|
||||||
border: 1px solid var(--gray500);
|
border: 1px solid var(--gray500);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -16,5 +17,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
|
color: var(--gray900);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
.container {
|
.container {
|
||||||
color: var(--gray500);
|
color: var(--gray500);
|
||||||
|
font-size: var(--font-size-normal);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
@ -10,9 +10,9 @@ import { getDateRange } from '../../lib/date';
|
|||||||
|
|
||||||
export default function RefreshButton({ websiteId }) {
|
export default function RefreshButton({ websiteId }) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const dateRange = useDateRange(websiteId);
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const [loading, setLoading] = useState(false);
|
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() {
|
function handleClick() {
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
@ -28,7 +28,7 @@ export default function RefreshButton({ websiteId }) {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
icon={loading ? <Dots /> : <Refresh />}
|
icon={loading ? <Dots /> : <Refresh />}
|
||||||
tooltip={<FormattedMessage id="button.refresh" defaultMessage="Refresh" />}
|
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
|
||||||
tooltipId="button-refresh"
|
tooltipId="button-refresh"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
@ -3,13 +3,23 @@ import classNames from 'classnames';
|
|||||||
import NoData from 'components/common/NoData';
|
import NoData from 'components/common/NoData';
|
||||||
import styles from './Table.module.css';
|
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) {
|
if (empty && rows.length === 0) {
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.table}>
|
<div className={classNames(styles.table, className)}>
|
||||||
|
{showHeader && (
|
||||||
<div className={classNames(styles.header, 'row')}>
|
<div className={classNames(styles.header, 'row')}>
|
||||||
{columns.map(({ key, label, className, style, header }) => (
|
{columns.map(({ key, label, className, style, header }) => (
|
||||||
<div
|
<div
|
||||||
@ -21,13 +31,25 @@ export default function Table({ columns, rows, empty }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
)}
|
||||||
|
<div className={classNames(styles.body, bodyClassName)}>
|
||||||
{rows.length === 0 && <NoData />}
|
{rows.length === 0 && <NoData />}
|
||||||
{rows.map((row, rowIndex) => (
|
{!children &&
|
||||||
<div className={classNames(styles.row, 'row')} key={rowIndex}>
|
rows.map((row, index) => {
|
||||||
{columns.map(({ key, render, className, style, cell }) => (
|
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
|
<div
|
||||||
key={`${rowIndex}${key}`}
|
key={`${key}-${index}`}
|
||||||
className={classNames(styles.cell, className, cell?.className)}
|
className={classNames(styles.cell, className, cell?.className)}
|
||||||
style={{ ...style, ...cell?.style }}
|
style={{ ...style, ...cell?.style }}
|
||||||
>
|
>
|
||||||
@ -35,8 +57,4 @@ export default function Table({ columns, rows, empty }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
7
components/common/Tag.js
Normal 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>;
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
.type {
|
.tag {
|
||||||
font-size: var(--font-size-small);
|
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border: 1px solid var(--gray300);
|
border: 1px solid var(--gray300);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
@ -9,7 +9,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
color: var(--gray50);
|
color: var(--msgColor);
|
||||||
background: var(--green400);
|
background: var(--green400);
|
||||||
margin: auto;
|
margin: auto;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
47
components/common/UpdateNotice.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
13
components/common/UpdateNotice.module.css
Normal 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;
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
@ -1,44 +1,61 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import ReactTooltip from 'react-tooltip';
|
import ReactTooltip from 'react-tooltip';
|
||||||
|
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import tinycolor from 'tinycolor2';
|
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 styles from './WorldMap.module.css';
|
||||||
|
import useCountryNames from 'hooks/useCountryNames';
|
||||||
|
import useLocale from 'hooks/useLocale';
|
||||||
|
|
||||||
const geoUrl = '/world-110m.json';
|
const geoUrl = '/world-110m.json';
|
||||||
|
|
||||||
export default function WorldMap({
|
export default function WorldMap({ data, className }) {
|
||||||
data,
|
|
||||||
className,
|
|
||||||
baseColor = '#e9f3fd',
|
|
||||||
fillColor = '#f5f5f5',
|
|
||||||
strokeColor = '#2680eb',
|
|
||||||
hoverColor = '#2680eb',
|
|
||||||
}) {
|
|
||||||
const [tooltip, setTooltip] = useState();
|
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) {
|
function getFillColor(code) {
|
||||||
if (code === 'AQ') return '#ffffff';
|
if (code === 'AQ') return;
|
||||||
const country = data?.find(({ x }) => x === code);
|
const country = data?.find(({ x }) => x === code);
|
||||||
return country ? tinycolor(baseColor).darken(country.z) : fillColor;
|
|
||||||
|
if (!country) {
|
||||||
|
return colors.fillColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStrokeColor(code) {
|
return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken'](
|
||||||
return code === 'AQ' ? '#ffffff' : strokeColor;
|
40 * (1.0 - country.z / 100),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHoverColor(code) {
|
function getOpacity(code) {
|
||||||
return code === 'AQ' ? '#ffffff' : hoverColor;
|
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);
|
const country = data?.find(({ x }) => x === code);
|
||||||
setTooltip(`${name}: ${country?.y || 0} visitors`);
|
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div
|
||||||
<ComposableMap data-tip="" projection="geoMercator">
|
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]}>
|
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
|
||||||
<Geographies geography={geoUrl}>
|
<Geographies geography={geoUrl}>
|
||||||
{({ geographies }) => {
|
{({ geographies }) => {
|
||||||
@ -50,13 +67,14 @@ export default function WorldMap({
|
|||||||
key={geo.rsmKey}
|
key={geo.rsmKey}
|
||||||
geography={geo}
|
geography={geo}
|
||||||
fill={getFillColor(code)}
|
fill={getFillColor(code)}
|
||||||
stroke={getStrokeColor(code)}
|
stroke={colors.strokeColor}
|
||||||
|
opacity={getOpacity(code)}
|
||||||
style={{
|
style={{
|
||||||
default: { outline: 'none' },
|
default: { outline: 'none' },
|
||||||
hover: { outline: 'none', fill: getHoverColor(code) },
|
hover: { outline: 'none', fill: colors.hoverColor },
|
||||||
pressed: { outline: 'none' },
|
pressed: { outline: 'none' },
|
||||||
}}
|
}}
|
||||||
onMouseOver={() => handleHover(geo.properties)}
|
onMouseOver={() => handleHover(code)}
|
||||||
onMouseOut={() => setTooltip(null)}
|
onMouseOut={() => setTooltip(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -65,7 +83,7 @@ export default function WorldMap({
|
|||||||
</Geographies>
|
</Geographies>
|
||||||
</ZoomableGroup>
|
</ZoomableGroup>
|
||||||
</ComposableMap>
|
</ComposableMap>
|
||||||
<ReactTooltip>{tooltip}</ReactTooltip>
|
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
.container {
|
.container {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form, Field } from 'formik';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { post } from 'lib/web';
|
import { post } from 'lib/web';
|
||||||
import Button from 'components/common/Button';
|
import Button from 'components/common/Button';
|
||||||
import FormLayout, {
|
import FormLayout, {
|
||||||
@ -29,18 +30,17 @@ const validate = ({ user_id, username, password }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountEditForm({ values, onSave, onClose }) {
|
export default function AccountEditForm({ values, onSave, onClose }) {
|
||||||
|
const { basePath } = useRouter();
|
||||||
const [message, setMessage] = useState();
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
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();
|
onSave();
|
||||||
} else {
|
} else {
|
||||||
setMessage(
|
setMessage(
|
||||||
response || (
|
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||||
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -58,22 +58,26 @@ export default function AccountEditForm({ values, onSave, onClose }) {
|
|||||||
<label htmlFor="username">
|
<label htmlFor="username">
|
||||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="username" type="text" />
|
<Field name="username" type="text" />
|
||||||
<FormError name="username" />
|
<FormError name="username" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label htmlFor="password">
|
<label htmlFor="password">
|
||||||
<FormattedMessage id="label.password" defaultMessage="Password" />
|
<FormattedMessage id="label.password" defaultMessage="Password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="password" type="password" />
|
<Field name="password" type="password" />
|
||||||
<FormError name="password" />
|
<FormError name="password" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button type="submit" variant="action">
|
<Button type="submit" variant="action">
|
||||||
<FormattedMessage id="button.save" defaultMessage="Save" />
|
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
<FormMessage>{message}</FormMessage>
|
<FormMessage>{message}</FormMessage>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form, Field } from 'formik';
|
||||||
import { post } from 'lib/web';
|
import { post } from 'lib/web';
|
||||||
import Button from 'components/common/Button';
|
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 }) {
|
export default function ChangePasswordForm({ values, onSave, onClose }) {
|
||||||
|
const { basePath } = useRouter();
|
||||||
const [message, setMessage] = useState();
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
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();
|
onSave();
|
||||||
} else {
|
} else {
|
||||||
setMessage(
|
setMessage(
|
||||||
response || (
|
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||||
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -66,29 +66,35 @@ export default function ChangePasswordForm({ values, onSave, onClose }) {
|
|||||||
<label htmlFor="current_password">
|
<label htmlFor="current_password">
|
||||||
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
|
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="current_password" type="password" />
|
<Field name="current_password" type="password" />
|
||||||
<FormError name="current_password" />
|
<FormError name="current_password" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label htmlFor="new_password">
|
<label htmlFor="new_password">
|
||||||
<FormattedMessage id="label.new-password" defaultMessage="New password" />
|
<FormattedMessage id="label.new-password" defaultMessage="New password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="new_password" type="password" />
|
<Field name="new_password" type="password" />
|
||||||
<FormError name="new_password" />
|
<FormError name="new_password" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label htmlFor="confirm_password">
|
<label htmlFor="confirm_password">
|
||||||
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
|
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="confirm_password" type="password" />
|
<Field name="confirm_password" type="password" />
|
||||||
<FormError name="confirm_password" />
|
<FormError name="confirm_password" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button type="submit" variant="action">
|
<Button type="submit" variant="action">
|
||||||
<FormattedMessage id="button.save" defaultMessage="Save" />
|
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
<FormMessage>{message}</FormMessage>
|
<FormMessage>{message}</FormMessage>
|
||||||
|
@ -6,7 +6,7 @@ import Button from 'components/common/Button';
|
|||||||
import { FormButtons } from 'components/layout/FormLayout';
|
import { FormButtons } from 'components/layout/FormLayout';
|
||||||
import { getDateRangeValues } from 'lib/date';
|
import { getDateRangeValues } from 'lib/date';
|
||||||
import styles from './DatePickerForm.module.css';
|
import styles from './DatePickerForm.module.css';
|
||||||
import ButtonGroup from '../common/ButtonGroup';
|
import ButtonGroup from 'components/common/ButtonGroup';
|
||||||
|
|
||||||
const FILTER_DAY = 0;
|
const FILTER_DAY = 0;
|
||||||
const FILTER_RANGE = 1;
|
const FILTER_RANGE = 1;
|
||||||
@ -33,11 +33,11 @@ export default function DatePickerForm({
|
|||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
label: <FormattedMessage id="button.single-day" defaultMessage="Single day" />,
|
label: <FormattedMessage id="label.single-day" defaultMessage="Single day" />,
|
||||||
value: FILTER_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,
|
value: FILTER_RANGE,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -72,10 +72,10 @@ export default function DatePickerForm({
|
|||||||
</div>
|
</div>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button variant="action" onClick={handleSave} disabled={disabled}>
|
<Button variant="action" onClick={handleSave} disabled={disabled}>
|
||||||
<FormattedMessage id="button.save" defaultMessage="Save" />
|
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form, Field } from 'formik';
|
||||||
import { del } from 'lib/web';
|
import { del } from 'lib/web';
|
||||||
import Button from 'components/common/Button';
|
import Button from 'components/common/Button';
|
||||||
@ -8,7 +10,6 @@ import FormLayout, {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
FormRow,
|
FormRow,
|
||||||
} from 'components/layout/FormLayout';
|
} from 'components/layout/FormLayout';
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
const CONFIRMATION_WORD = 'DELETE';
|
const CONFIRMATION_WORD = 'DELETE';
|
||||||
|
|
||||||
@ -27,15 +28,18 @@ const validate = ({ confirmation }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DeleteForm({ values, onSave, onClose }) {
|
export default function DeleteForm({ values, onSave, onClose }) {
|
||||||
|
const { basePath } = useRouter();
|
||||||
const [message, setMessage] = useState();
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
const handleSubmit = async ({ type, id }) => {
|
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();
|
onSave();
|
||||||
} else {
|
} 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>
|
</p>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
|
<div>
|
||||||
<Field name="confirmation" type="text" />
|
<Field name="confirmation" type="text" />
|
||||||
<FormError name="confirmation" />
|
<FormError name="confirmation" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button
|
<Button
|
||||||
@ -78,10 +84,10 @@ export default function DeleteForm({ values, onSave, onClose }) {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={props.values.confirmation !== CONFIRMATION_WORD}
|
disabled={props.values.confirmation !== CONFIRMATION_WORD}
|
||||||
>
|
>
|
||||||
<FormattedMessage id="button.delete" defaultMessage="Delete" />
|
<FormattedMessage id="label.delete" defaultMessage="Delete" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
<FormMessage>{message}</FormMessage>
|
<FormMessage>{message}</FormMessage>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form, Field } from 'formik';
|
||||||
import Router from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { post } from 'lib/web';
|
import { post } from 'lib/web';
|
||||||
import Button from 'components/common/Button';
|
import Button from 'components/common/Button';
|
||||||
import FormLayout, {
|
import FormLayout, {
|
||||||
@ -28,22 +28,26 @@ const validate = ({ username, password }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
const [message, setMessage] = useState();
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
const handleSubmit = async ({ username, password }) => {
|
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') {
|
if (ok) {
|
||||||
await Router.push('/');
|
return router.push('/');
|
||||||
} else {
|
} else {
|
||||||
setMessage(
|
setMessage(
|
||||||
response.startsWith('401') ? (
|
status === 401 ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="message.incorrect-username-password"
|
id="message.incorrect-username-password"
|
||||||
defaultMessage="Incorrect username/password."
|
defaultMessage="Incorrect username/password."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
response
|
data
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -67,19 +71,23 @@ export default function LoginForm() {
|
|||||||
<label htmlFor="username">
|
<label htmlFor="username">
|
||||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="username" type="text" />
|
<Field name="username" type="text" />
|
||||||
<FormError name="username" />
|
<FormError name="username" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label htmlFor="password">
|
<label htmlFor="password">
|
||||||
<FormattedMessage id="label.password" defaultMessage="Password" />
|
<FormattedMessage id="label.password" defaultMessage="Password" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="password" type="password" />
|
<Field name="password" type="password" />
|
||||||
<FormError name="password" />
|
<FormError name="password" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button type="submit" variant="action">
|
<Button type="submit" variant="action">
|
||||||
<FormattedMessage id="button.login" defaultMessage="Login" />
|
<FormattedMessage id="label.login" defaultMessage="Login" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
<FormMessage>{message}</FormMessage>
|
<FormMessage>{message}</FormMessage>
|
||||||
|
@ -30,7 +30,7 @@ export default function TrackingCodeForm({ values, onClose }) {
|
|||||||
<FormButtons>
|
<FormButtons>
|
||||||
<CopyButton type="submit" variant="action" element={ref} />
|
<CopyButton type="submit" variant="action" element={ref} />
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</FormLayout>
|
</FormLayout>
|
||||||
|
@ -29,7 +29,7 @@ export default function TrackingCodeForm({ values, onClose }) {
|
|||||||
<FormButtons>
|
<FormButtons>
|
||||||
<CopyButton type="submit" variant="action" element={ref} />
|
<CopyButton type="submit" variant="action" element={ref} />
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</FormLayout>
|
</FormLayout>
|
||||||
|
@ -11,6 +11,7 @@ import FormLayout, {
|
|||||||
} from 'components/layout/FormLayout';
|
} from 'components/layout/FormLayout';
|
||||||
import Checkbox from 'components/common/Checkbox';
|
import Checkbox from 'components/common/Checkbox';
|
||||||
import { DOMAIN_REGEX } from 'lib/constants';
|
import { DOMAIN_REGEX } from 'lib/constants';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@ -34,15 +35,18 @@ const validate = ({ name, domain }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WebsiteEditForm({ values, onSave, onClose }) {
|
export default function WebsiteEditForm({ values, onSave, onClose }) {
|
||||||
|
const { basePath } = useRouter();
|
||||||
const [message, setMessage] = useState();
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
const handleSubmit = async values => {
|
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();
|
onSave();
|
||||||
} else {
|
} 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">
|
<label htmlFor="name">
|
||||||
<FormattedMessage id="label.name" defaultMessage="Name" />
|
<FormattedMessage id="label.name" defaultMessage="Name" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="name" type="text" />
|
<Field name="name" type="text" />
|
||||||
<FormError name="name" />
|
<FormError name="name" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label htmlFor="domain">
|
<label htmlFor="domain">
|
||||||
<FormattedMessage id="label.domain" defaultMessage="Domain" />
|
<FormattedMessage id="label.domain" defaultMessage="Domain" />
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
<Field name="domain" type="text" />
|
<Field name="domain" type="text" />
|
||||||
<FormError name="domain" />
|
<FormError name="domain" />
|
||||||
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<label></label>
|
<label></label>
|
||||||
@ -87,10 +95,10 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button type="submit" variant="action">
|
<Button type="submit" variant="action">
|
||||||
<FormattedMessage id="button.save" defaultMessage="Save" />
|
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id="button.cancel" defaultMessage="Cancel" />
|
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
<FormMessage>{message}</FormMessage>
|
<FormMessage>{message}</FormMessage>
|
||||||
|
@ -2,6 +2,16 @@ import React from 'react';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import styles from './ButtonLayout.module.css';
|
import styles from './ButtonLayout.module.css';
|
||||||
|
|
||||||
export default function ButtonLayout({ className, children }) {
|
export default function ButtonLayout({ className, children, align = 'center' }) {
|
||||||
return <div className={classNames(styles.buttons, className)}>{children}</div>;
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.buttons, className, {
|
||||||
|
[styles.left]: align === 'left',
|
||||||
|
[styles.center]: align === 'center',
|
||||||
|
[styles.right]: align === 'right',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,3 +6,15 @@
|
|||||||
.buttons button + * {
|
.buttons button + * {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Link from 'components/common/Link';
|
import Link from 'components/common/Link';
|
||||||
import styles from './Footer.module.css';
|
import styles from './Footer.module.css';
|
||||||
|
import useVersion from 'hooks/useVersion';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const version = process.env.VERSION;
|
const { current } = useVersion();
|
||||||
return (
|
return (
|
||||||
<footer className="container">
|
<footer className="container">
|
||||||
<div className={styles.footer}>
|
<div className={classNames(styles.footer, 'row')}>
|
||||||
<div />
|
<div className="col-12 col-md-4" />
|
||||||
<div>
|
<div className="col-12 col-md-4">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="message.powered-by"
|
id="message.powered-by"
|
||||||
defaultMessage="Powered by {name}"
|
defaultMessage="Powered by {name}"
|
||||||
@ -22,7 +24,9 @@ export default function Footer() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
@ -4,4 +4,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.version {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,10 @@
|
|||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row > div {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -33,13 +37,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 100%;
|
left: calc(100% + 16px);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin-left: 16px;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
color: var(--gray50);
|
color: var(--msgColor);
|
||||||
background: var(--red400);
|
background: var(--red400);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -68,3 +72,15 @@
|
|||||||
color: var(--gray50);
|
color: var(--gray50);
|
||||||
background: var(--gray800);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
31
components/layout/GridLayout.js
Normal 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>;
|
||||||
|
};
|
40
components/layout/GridLayout.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -3,41 +3,48 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Link from 'components/common/Link';
|
import Link from 'components/common/Link';
|
||||||
import UserButton from '../common/UserButton';
|
import Icon from 'components/common/Icon';
|
||||||
import Icon from '../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 Logo from 'assets/logo.svg';
|
||||||
import styles from './Header.module.css';
|
import styles from './Header.module.css';
|
||||||
import LanguageButton from '../common/LanguageButton';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const user = useSelector(state => state.user);
|
const user = useSelector(state => state.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="container">
|
<header className="container">
|
||||||
|
{user?.is_admin && <UpdateNotice />}
|
||||||
<div className={classNames(styles.header, 'row align-items-center')}>
|
<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}>
|
<div className={styles.title}>
|
||||||
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
||||||
<Link href={user ? '/' : 'https://umami.is'}>umami</Link>
|
<Link href={user ? '/' : 'https://umami.is'}>umami</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-md-9">
|
<div className="col-12 col-md-12 col-lg-6">
|
||||||
|
{user && (
|
||||||
<div className={styles.nav}>
|
<div className={styles.nav}>
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
|
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/realtime">
|
||||||
|
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
|
||||||
|
</Link>
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
<FormattedMessage id="label.settings" defaultMessage="Settings" />
|
<FormattedMessage id="label.settings" defaultMessage="Settings" />
|
||||||
</Link>
|
</Link>
|
||||||
<LanguageButton menuAlign="right" />
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--font-size-large);
|
font-size: var(--font-size-large);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@ -13,18 +16,34 @@
|
|||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: var(--font-size-normal);
|
font-size: var(--font-size-normal);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav > * {
|
.nav a + a {
|
||||||
margin-left: 40px;
|
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 {
|
.title {
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
font-size: var(--font-size-large);
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,8 @@ export default function Layout({ title, children, header = true, footer = true }
|
|||||||
</Head>
|
</Head>
|
||||||
{header && <Header />}
|
{header && <Header />}
|
||||||
<main className="container">{children}</main>
|
<main className="container">{children}</main>
|
||||||
<div id="__modals" />
|
|
||||||
{footer && <Footer />}
|
{footer && <Footer />}
|
||||||
|
<div id="__modals" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
6
components/layout/Layout.module.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import styles from './Page.module.css';
|
import styles from './Page.module.css';
|
||||||
|
|
||||||
export default function Page({ children }) {
|
export default function Page({ className, children }) {
|
||||||
return <div className={styles.page}>{children}</div>;
|
return <div className={classNames(styles.page, className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
.page {
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
background: var(--gray50);
|
background: var(--gray50);
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import styles from './PageHeader.module.css';
|
import styles from './PageHeader.module.css';
|
||||||
|
|
||||||
export default function PageHeader({ children }) {
|
export default function PageHeader({ children, className }) {
|
||||||
return <div className={styles.header}>{children}</div>;
|
return <div className={classNames(styles.header, className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
@ -4,4 +4,5 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import useFetch from 'hooks/useFetch';
|
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 styles from './ActiveUsers.module.css';
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
export default function ActiveUsers({ websiteId, token, className }) {
|
export default function ActiveUsers({ websiteId, className }) {
|
||||||
const { data } = useFetch(`/api/website/${websiteId}/active`, { token }, { interval: 60000 });
|
const shareToken = useShareToken();
|
||||||
|
const { data } = useFetch(`/api/website/${websiteId}/active`, {
|
||||||
|
interval: 60000,
|
||||||
|
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||||
|
});
|
||||||
const count = useMemo(() => {
|
const count = useMemo(() => {
|
||||||
return data?.[0]?.x || 0;
|
return data?.[0]?.x || 0;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@ -16,7 +23,7 @@ export default function ActiveUsers({ websiteId, token, className }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div className={classNames(styles.container, className)}>
|
||||||
<div className={styles.dot} />
|
<Dot />
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<div>
|
<div>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -12,11 +12,3 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
|
||||||
background: var(--green400);
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 100%;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
@ -1,34 +1,50 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import ReactTooltip from 'react-tooltip';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ChartJS from 'chart.js';
|
import ChartJS from 'chart.js';
|
||||||
|
import Legend from 'components/metrics/Legend';
|
||||||
import { formatLongNumber } from 'lib/format';
|
import { formatLongNumber } from 'lib/format';
|
||||||
import { dateFormat } from 'lib/lang';
|
import { dateFormat } from 'lib/lang';
|
||||||
import useLocale from 'hooks/useLocale';
|
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 styles from './BarChart.module.css';
|
||||||
|
import ChartTooltip from './ChartTooltip';
|
||||||
|
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||||
|
|
||||||
export default function BarChart({
|
export default function BarChart({
|
||||||
chartId,
|
chartId,
|
||||||
datasets,
|
datasets,
|
||||||
unit,
|
unit,
|
||||||
records,
|
records,
|
||||||
height = 400,
|
height = DEFAUL_CHART_HEIGHT,
|
||||||
animationDuration = 300,
|
animationDuration = DEFAULT_ANIMATION_DURATION,
|
||||||
className,
|
className,
|
||||||
stacked = false,
|
stacked = false,
|
||||||
|
loading = false,
|
||||||
onCreate = () => {},
|
onCreate = () => {},
|
||||||
onUpdate = () => {},
|
onUpdate = () => {},
|
||||||
}) {
|
}) {
|
||||||
const canvas = useRef();
|
const canvas = useRef();
|
||||||
const chart = useRef();
|
const chart = useRef();
|
||||||
const [tooltip, setTooltip] = useState({});
|
const [tooltip, setTooltip] = useState(null);
|
||||||
const [locale] = useLocale();
|
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) {
|
function renderXLabel(label, index, values) {
|
||||||
|
if (loading) return '';
|
||||||
const d = new Date(values[index].value);
|
const d = new Date(values[index].value);
|
||||||
const w = canvas.current.width;
|
const w = canvas.current.width;
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
|
case 'minute':
|
||||||
|
return index % 2 === 0 ? dateFormat(d, 'h:mm', locale) : '';
|
||||||
case 'hour':
|
case 'hour':
|
||||||
return dateFormat(d, 'ha', locale);
|
return dateFormat(d, 'ha', locale);
|
||||||
case 'day':
|
case 'day':
|
||||||
@ -53,15 +69,17 @@ export default function BarChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderYLabel(label) {
|
function renderYLabel(label) {
|
||||||
return +label > 1 ? formatLongNumber(label) : label;
|
return +label > 1000 ? formatLongNumber(label) : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTooltip(model) {
|
function renderTooltip(model) {
|
||||||
const { opacity, title, body, labelColors } = model;
|
const { opacity, title, body, labelColors } = model;
|
||||||
|
|
||||||
if (!opacity) {
|
if (!opacity || !title) {
|
||||||
setTooltip(null);
|
setTooltip(null);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [label, value] = body[0].lines[0].split(':');
|
const [label, value] = body[0].lines[0].split(':');
|
||||||
|
|
||||||
setTooltip({
|
setTooltip({
|
||||||
@ -71,7 +89,6 @@ export default function BarChart({
|
|||||||
labelColor: labelColors[0].backgroundColor,
|
labelColor: labelColors[0].backgroundColor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getTooltipFormat(unit) {
|
function getTooltipFormat(unit) {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
@ -97,6 +114,9 @@ export default function BarChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
responsiveAnimationDuration: 0,
|
responsiveAnimationDuration: 0,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
xAxes: [
|
xAxes: [
|
||||||
{
|
{
|
||||||
@ -110,6 +130,7 @@ export default function BarChart({
|
|||||||
callback: renderXLabel,
|
callback: renderXLabel,
|
||||||
minRotation: 0,
|
minRotation: 0,
|
||||||
maxRotation: 0,
|
maxRotation: 0,
|
||||||
|
fontColor: colors.text,
|
||||||
},
|
},
|
||||||
gridLines: {
|
gridLines: {
|
||||||
display: false,
|
display: false,
|
||||||
@ -123,6 +144,11 @@ export default function BarChart({
|
|||||||
ticks: {
|
ticks: {
|
||||||
callback: renderYLabel,
|
callback: renderYLabel,
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
|
fontColor: colors.text,
|
||||||
|
},
|
||||||
|
gridLines: {
|
||||||
|
color: colors.line,
|
||||||
|
zeroLineColor: colors.zeroLine,
|
||||||
},
|
},
|
||||||
stacked,
|
stacked,
|
||||||
},
|
},
|
||||||
@ -144,12 +170,21 @@ export default function BarChart({
|
|||||||
function updateChart() {
|
function updateChart() {
|
||||||
const { options } = chart.current;
|
const { options } = chart.current;
|
||||||
|
|
||||||
|
options.legend.labels.fontColor = colors.text;
|
||||||
options.scales.xAxes[0].time.unit = unit;
|
options.scales.xAxes[0].time.unit = unit;
|
||||||
options.scales.xAxes[0].ticks.callback = renderXLabel;
|
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.animation.duration = animationDuration;
|
||||||
options.tooltips.custom = renderTooltip;
|
options.tooltips.custom = renderTooltip;
|
||||||
|
|
||||||
onUpdate(chart.current);
|
onUpdate(chart.current);
|
||||||
|
|
||||||
|
chart.current.update();
|
||||||
|
|
||||||
|
forceUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -161,7 +196,7 @@ export default function BarChart({
|
|||||||
updateChart();
|
updateChart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [datasets, unit, animationDuration, locale]);
|
}, [datasets, unit, animationDuration, locale, theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -173,23 +208,8 @@ export default function BarChart({
|
|||||||
>
|
>
|
||||||
<canvas ref={canvas} />
|
<canvas ref={canvas} />
|
||||||
</div>
|
</div>
|
||||||
<ReactTooltip id={`${chartId}-tooltip`}>
|
<Legend chart={chart.current} />
|
||||||
{tooltip ? <Tooltip {...tooltip} /> : null}
|
<ChartTooltip chartId={chartId} tooltip={tooltip} />
|
||||||
</ReactTooltip>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
@ -1,43 +1,3 @@
|
|||||||
.chart {
|
.chart {
|
||||||
position: relative;
|
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;
|
|
||||||
}
|
|
||||||
|
@ -3,17 +3,15 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import { browserFilter } from 'lib/filters';
|
import { browserFilter } from 'lib/filters';
|
||||||
|
|
||||||
export default function BrowsersTable({ websiteId, token, limit, onExpand }) {
|
export default function BrowsersTable({ websiteId, ...props }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
{...props}
|
||||||
title={<FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />}
|
title={<FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />}
|
||||||
type="browser"
|
type="browser"
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
|
||||||
limit={limit}
|
|
||||||
dataFilter={browserFilter}
|
dataFilter={browserFilter}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
26
components/metrics/ChartTooltip.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
43
components/metrics/ChartTooltip.module.css
Normal 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;
|
||||||
|
}
|
@ -1,26 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MetricsTable from './MetricsTable';
|
import MetricsTable from './MetricsTable';
|
||||||
import { countryFilter, percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import { FormattedMessage } from 'react-intl';
|
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 (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
{...props}
|
||||||
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
||||||
type="country"
|
type="country"
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
onDataLoad={data => onDataLoad?.(percentFilter(data))}
|
||||||
limit={limit}
|
renderLabel={renderLabel}
|
||||||
dataFilter={countryFilter}
|
|
||||||
onDataLoad={data => onDataLoad(percentFilter(data))}
|
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
91
components/metrics/DataTable.js
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
95
components/metrics/DataTable.module.css
Normal 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;
|
||||||
|
}
|
@ -4,18 +4,16 @@ import { deviceFilter } from 'lib/filters';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { getDeviceMessage } from 'components/messages';
|
import { getDeviceMessage } from 'components/messages';
|
||||||
|
|
||||||
export default function DevicesTable({ websiteId, token, limit, onExpand }) {
|
export default function DevicesTable({ websiteId, ...props }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
{...props}
|
||||||
title={<FormattedMessage id="metrics.devices" defaultMessage="Devices" />}
|
title={<FormattedMessage id="metrics.devices" defaultMessage="Devices" />}
|
||||||
type="device"
|
type="device"
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
|
||||||
limit={limit}
|
|
||||||
dataFilter={deviceFilter}
|
dataFilter={deviceFilter}
|
||||||
renderLabel={({ x }) => getDeviceMessage(x)}
|
renderLabel={({ x }) => getDeviceMessage(x)}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,40 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
import BarChart from './BarChart';
|
import BarChart from './BarChart';
|
||||||
import { getTimezone, getDateArray, getDateLength } from 'lib/date';
|
import { getDateArray, getDateLength } from 'lib/date';
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
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 = [
|
export default function EventsChart({ websiteId, className, token }) {
|
||||||
'#2680eb',
|
const [dateRange] = useDateRange(websiteId);
|
||||||
'#9256d9',
|
|
||||||
'#44b556',
|
|
||||||
'#e68619',
|
|
||||||
'#e34850',
|
|
||||||
'#1b959a',
|
|
||||||
'#d83790',
|
|
||||||
'#85d044',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EventsChart({ websiteId, token }) {
|
|
||||||
const dateRange = useDateRange(websiteId);
|
|
||||||
const { startDate, endDate, unit, modified } = dateRange;
|
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`,
|
`/api/website/${websiteId}/events`,
|
||||||
{
|
{
|
||||||
|
params: {
|
||||||
start_at: +startDate,
|
start_at: +startDate,
|
||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
unit,
|
unit,
|
||||||
tz: getTimezone(),
|
tz: timezone,
|
||||||
|
url: query.url,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
{ update: [modified] },
|
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||||
|
},
|
||||||
|
[modified],
|
||||||
);
|
);
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
const datasets = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
if (loading) return data;
|
||||||
|
|
||||||
const map = data.reduce((obj, { x, t, y }) => {
|
const map = data.reduce((obj, { x, t, y }) => {
|
||||||
if (!obj[x]) {
|
if (!obj[x]) {
|
||||||
@ -48,25 +51,17 @@ export default function EventsChart({ websiteId, token }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Object.keys(map).map((key, index) => {
|
return Object.keys(map).map((key, index) => {
|
||||||
const color = tinycolor(COLORS[index]);
|
const color = tinycolor(EVENT_COLORS[index % EVENT_COLORS.length]);
|
||||||
return {
|
return {
|
||||||
label: key,
|
label: key,
|
||||||
data: map[key],
|
data: map[key],
|
||||||
lineTension: 0,
|
lineTension: 0,
|
||||||
backgroundColor: color.setAlpha(0.4).toRgbString(),
|
backgroundColor: color.setAlpha(0.6).toRgbString(),
|
||||||
borderColor: color.setAlpha(0.5).toRgbString(),
|
borderColor: color.setAlpha(0.7).toRgbString(),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data, loading]);
|
||||||
|
|
||||||
function handleCreate(options) {
|
|
||||||
const legend = {
|
|
||||||
position: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
options.legend = legend;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdate(chart) {
|
function handleUpdate(chart) {
|
||||||
chart.data.datasets = datasets;
|
chart.data.datasets = datasets;
|
||||||
@ -81,11 +76,13 @@ export default function EventsChart({ websiteId, token }) {
|
|||||||
return (
|
return (
|
||||||
<BarChart
|
<BarChart
|
||||||
chartId={`events-${websiteId}`}
|
chartId={`events-${websiteId}`}
|
||||||
|
className={className}
|
||||||
datasets={datasets}
|
datasets={datasets}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
height={300}
|
||||||
records={getDateLength(startDate, endDate, unit)}
|
records={getDateLength(startDate, endDate, unit)}
|
||||||
onCreate={handleCreate}
|
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
|
loading={loading}
|
||||||
stacked
|
stacked
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -1,20 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import MetricsTable from './MetricsTable';
|
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 (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
{...props}
|
||||||
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
||||||
type="event"
|
type="event"
|
||||||
metric={<FormattedMessage id="metrics.actions" defaultMessage="Actions" />}
|
metric={<FormattedMessage id="metrics.actions" defaultMessage="Actions" />}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
|
||||||
limit={limit}
|
|
||||||
renderLabel={({ x }) => <Label value={x} />}
|
renderLabel={({ x }) => <Label value={x} />}
|
||||||
onExpand={onExpand}
|
|
||||||
onDataLoad={onDataLoad}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -23,7 +20,7 @@ const Label = ({ value }) => {
|
|||||||
const [event, label] = value.split(':');
|
const [event, label] = value.split(':');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={styles.type}>{event}</span>
|
<Tag>{event}</Tag>
|
||||||
{label}
|
{label}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
40
components/metrics/Legend.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
21
components/metrics/Legend.module.css
Normal 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);
|
||||||
|
}
|
@ -3,7 +3,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding-right: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
|
@ -2,27 +2,37 @@ import React, { useState } from 'react';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Loading from 'components/common/Loading';
|
import Loading from 'components/common/Loading';
|
||||||
|
import ErrorMessage from 'components/common/ErrorMessage';
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import useDateRange from 'hooks/useDateRange';
|
import useDateRange from 'hooks/useDateRange';
|
||||||
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
|
import useShareToken from 'hooks/useShareToken';
|
||||||
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
|
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
|
||||||
|
import { TOKEN_HEADER } from 'lib/constants';
|
||||||
import MetricCard from './MetricCard';
|
import MetricCard from './MetricCard';
|
||||||
import styles from './MetricsBar.module.css';
|
import styles from './MetricsBar.module.css';
|
||||||
|
|
||||||
export default function MetricsBar({ websiteId, token, className }) {
|
export default function MetricsBar({ websiteId, className }) {
|
||||||
const dateRange = useDateRange(websiteId);
|
const shareToken = useShareToken();
|
||||||
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, modified } = dateRange;
|
const { startDate, endDate, modified } = dateRange;
|
||||||
const { data } = useFetch(
|
const [format, setFormat] = useState(true);
|
||||||
`/api/website/${websiteId}/metrics`,
|
const {
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
|
const { data, error, loading } = useFetch(
|
||||||
|
`/api/website/${websiteId}/stats`,
|
||||||
{
|
{
|
||||||
|
params: {
|
||||||
start_at: +startDate,
|
start_at: +startDate,
|
||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
token,
|
url,
|
||||||
},
|
},
|
||||||
{
|
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||||
update: [modified],
|
|
||||||
},
|
},
|
||||||
|
[url, modified],
|
||||||
);
|
);
|
||||||
const [format, setFormat] = useState(true);
|
|
||||||
|
|
||||||
const formatFunc = format ? formatLongNumber : formatNumber;
|
const formatFunc = format ? formatLongNumber : formatNumber;
|
||||||
|
|
||||||
@ -34,9 +44,9 @@ export default function MetricsBar({ websiteId, token, className }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
|
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
|
||||||
{!data ? (
|
{!data && loading && <Loading />}
|
||||||
<Loading />
|
{error && <ErrorMessage />}
|
||||||
) : (
|
{data && !error && (
|
||||||
<>
|
<>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
label={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar > div + div {
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
@media only screen and (max-width: 992px) {
|
||||||
.bar {
|
.bar {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.bar > div:last-child {
|
.bar > div:nth-child(n + 3) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,143 +1,86 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { FixedSizeList } from 'react-window';
|
import firstBy from 'thenby';
|
||||||
import { useSpring, animated, config } from 'react-spring';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Button from 'components/common/Button';
|
import Link from 'components/common/Link';
|
||||||
import Loading from 'components/common/Loading';
|
import Loading from 'components/common/Loading';
|
||||||
import NoData from 'components/common/NoData';
|
|
||||||
import useFetch from 'hooks/useFetch';
|
import useFetch from 'hooks/useFetch';
|
||||||
import Arrow from 'assets/arrow-right.svg';
|
import Arrow from 'assets/arrow-right.svg';
|
||||||
import { percentFilter } from 'lib/filters';
|
import { percentFilter } from 'lib/filters';
|
||||||
import { formatNumber, formatLongNumber } from 'lib/format';
|
|
||||||
import useDateRange from 'hooks/useDateRange';
|
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';
|
import styles from './MetricsTable.module.css';
|
||||||
|
|
||||||
export default function MetricsTable({
|
export default function MetricsTable({
|
||||||
websiteId,
|
websiteId,
|
||||||
websiteDomain,
|
websiteDomain,
|
||||||
token,
|
|
||||||
title,
|
|
||||||
metric,
|
|
||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
dataFilter,
|
dataFilter,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
limit,
|
limit,
|
||||||
headerComponent,
|
onDataLoad,
|
||||||
renderLabel,
|
...props
|
||||||
onDataLoad = () => {},
|
|
||||||
onExpand = () => {},
|
|
||||||
}) {
|
}) {
|
||||||
const dateRange = useDateRange(websiteId);
|
const shareToken = useShareToken();
|
||||||
|
const [dateRange] = useDateRange(websiteId);
|
||||||
const { startDate, endDate, modified } = dateRange;
|
const { startDate, endDate, modified } = dateRange;
|
||||||
const { data } = useFetch(
|
const {
|
||||||
`/api/website/${websiteId}/rankings`,
|
resolve,
|
||||||
|
router,
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
|
const { data, loading, error } = useFetch(
|
||||||
|
`/api/website/${websiteId}/metrics`,
|
||||||
{
|
{
|
||||||
|
params: {
|
||||||
type,
|
type,
|
||||||
start_at: +startDate,
|
start_at: +startDate,
|
||||||
end_at: +endDate,
|
end_at: +endDate,
|
||||||
domain: websiteDomain,
|
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) {
|
if (data) {
|
||||||
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
|
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
|
||||||
if (limit) {
|
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 [];
|
return [];
|
||||||
}, [data, dataFilter, filterOptions]);
|
}, [data, error, 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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<div className={classNames(styles.container, className)}>
|
||||||
{!data && <Loading />}
|
{!data && loading && <Loading />}
|
||||||
{data && (
|
{error && <ErrorMessage />}
|
||||||
<>
|
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
||||||
<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>
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{limit && data.length > limit && (
|
{data && !error && limit && (
|
||||||
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
|
<Link
|
||||||
<div>
|
icon={<Arrow />}
|
||||||
<FormattedMessage id="button.more" defaultMessage="More" />
|
href={router.pathname}
|
||||||
</div>
|
as={resolve({ view: type })}
|
||||||
</Button>
|
size="small"
|
||||||
|
iconRight
|
||||||
|
>
|
||||||
|
<FormattedMessage id="label.more" defaultMessage="More" />
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -1,100 +1,11 @@
|
|||||||
.container {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 460px;
|
min-height: 430px;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
padding: 20px 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -3,17 +3,15 @@ import MetricsTable from './MetricsTable';
|
|||||||
import { osFilter } from 'lib/filters';
|
import { osFilter } from 'lib/filters';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default function OSTable({ websiteId, token, limit, onExpand }) {
|
export default function OSTable({ websiteId, ...props }) {
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
{...props}
|
||||||
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
|
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
|
||||||
type="os"
|
type="os"
|
||||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
|
||||||
limit={limit}
|
|
||||||
dataFilter={osFilter}
|
dataFilter={osFilter}
|
||||||
onExpand={onExpand}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
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 { urlFilter } from 'lib/filters';
|
||||||
import { FILTER_COMBINED, FILTER_RAW } from 'lib/constants';
|
import usePageQuery from 'hooks/usePageQuery';
|
||||||
import MetricsTable from './MetricsTable';
|
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 [filter, setFilter] = useState(FILTER_COMBINED);
|
||||||
|
const {
|
||||||
|
resolve,
|
||||||
|
query: { url },
|
||||||
|
} = usePageQuery();
|
||||||
|
|
||||||
const buttons = [
|
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 },
|
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderLink = ({ x }) => {
|
||||||
return (
|
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
|
<MetricsTable
|
||||||
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
||||||
type="url"
|
type="url"
|
||||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||||
headerComponent={
|
|
||||||
limit ? null : <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />
|
|
||||||
}
|
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
token={token}
|
|
||||||
limit={limit}
|
|
||||||
dataFilter={urlFilter}
|
dataFilter={urlFilter}
|
||||||
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
|
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
|
||||||
renderLabel={({ x }) => decodeURI(x)}
|
renderLabel={renderLink}
|
||||||
onExpand={onExpand}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilterButtons = ({ buttons, selected, onClick }) => {
|
|
||||||
return <ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />;
|
|
||||||
};
|
|
||||||
|
8
components/metrics/PagesTable.module.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
body .inactive {
|
||||||
|
color: var(--gray500);
|
||||||
|
}
|
||||||
|
|
||||||
|
body .active {
|
||||||
|
color: var(--gray900);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
@ -1,17 +1,41 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import tinycolor from 'tinycolor2';
|
||||||
import CheckVisible from 'components/helpers/CheckVisible';
|
import CheckVisible from 'components/helpers/CheckVisible';
|
||||||
import BarChart from './BarChart';
|
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 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 handleUpdate = chart => {
|
||||||
const {
|
const {
|
||||||
data: { datasets },
|
data: { datasets },
|
||||||
} = chart;
|
} = chart;
|
||||||
|
|
||||||
datasets[0].data = data.uniques;
|
datasets[0].data = data.sessions;
|
||||||
datasets[0].label = intl.formatMessage({
|
datasets[0].label = intl.formatMessage({
|
||||||
id: 'metrics.unique-visitors',
|
id: 'metrics.unique-visitors',
|
||||||
defaultMessage: 'Unique visitors',
|
defaultMessage: 'Unique visitors',
|
||||||
@ -21,8 +45,6 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
|
|||||||
id: 'metrics.page-views',
|
id: 'metrics.page-views',
|
||||||
defaultMessage: 'Page views',
|
defaultMessage: 'Page views',
|
||||||
});
|
});
|
||||||
|
|
||||||
chart.update();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@ -33,6 +55,7 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
|
|||||||
<CheckVisible>
|
<CheckVisible>
|
||||||
{visible => (
|
{visible => (
|
||||||
<BarChart
|
<BarChart
|
||||||
|
{...props}
|
||||||
className={className}
|
className={className}
|
||||||
chartId={websiteId}
|
chartId={websiteId}
|
||||||
datasets={[
|
datasets={[
|
||||||
@ -41,10 +64,10 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
|
|||||||
id: 'metrics.unique-visitors',
|
id: 'metrics.unique-visitors',
|
||||||
defaultMessage: 'Unique visitors',
|
defaultMessage: 'Unique visitors',
|
||||||
}),
|
}),
|
||||||
data: data.uniques,
|
data: data.sessions,
|
||||||
lineTension: 0,
|
lineTension: 0,
|
||||||
backgroundColor: 'rgb(38, 128, 235, 0.4)',
|
backgroundColor: colors.visitors.background,
|
||||||
borderColor: 'rgb(13, 102, 208, 0.4)',
|
borderColor: colors.visitors.border,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -54,15 +77,16 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
|
|||||||
}),
|
}),
|
||||||
data: data.pageviews,
|
data: data.pageviews,
|
||||||
lineTension: 0,
|
lineTension: 0,
|
||||||
backgroundColor: 'rgb(38, 128, 235, 0.2)',
|
backgroundColor: colors.views.background,
|
||||||
borderColor: 'rgb(13, 102, 208, 0.2)',
|
borderColor: colors.views.border,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
records={records}
|
records={records}
|
||||||
animationDuration={visible ? 300 : 0}
|
animationDuration={visible ? animationDuration : 0}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CheckVisible>
|
</CheckVisible>
|
||||||
|
60
components/metrics/RealtimeChart.js
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
49
components/metrics/RealtimeHeader.js
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|