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