mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Moved code into src folder. Added build for component library.
This commit is contained in:
parent
7a7233ead4
commit
ede658771e
490 changed files with 749 additions and 442 deletions
29
src/components/common/ConfirmDeleteForm.js
Normal file
29
src/components/common/ConfirmDeleteForm.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react';
|
||||
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function ConfirmDeleteForm({ name, onConfirm, onClose }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
|
||||
const handleConfirm = () => {
|
||||
setLoading(true);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<p>
|
||||
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{name}</b> }} />
|
||||
</p>
|
||||
<FormButtons flex>
|
||||
<LoadingButton loading={loading} onClick={handleConfirm} variant="danger">
|
||||
{formatMessage(labels.delete)}
|
||||
</LoadingButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDeleteForm;
|
||||
15
src/components/common/Empty.js
Normal file
15
src/components/common/Empty.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import classNames from 'classnames';
|
||||
import styles from './Empty.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function Empty({ message, className }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
{message || formatMessage(messages.noDataAvailable)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Empty;
|
||||
11
src/components/common/Empty.module.css
Normal file
11
src/components/common/Empty.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.container {
|
||||
color: var(--base500);
|
||||
font-size: var(--font-size-md);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
16
src/components/common/EmptyPlaceholder.js
Normal file
16
src/components/common/EmptyPlaceholder.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Icon, Text, Flexbox } from 'react-basics';
|
||||
import Logo from 'assets/logo.svg';
|
||||
|
||||
export function EmptyPlaceholder({ message, children }) {
|
||||
return (
|
||||
<Flexbox direction="column" alignItems="center" justifyContent="center" gap={60} height={600}>
|
||||
<Icon size="xl">
|
||||
<Logo />
|
||||
</Icon>
|
||||
<Text size="lg">{message}</Text>
|
||||
<div>{children}</div>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
32
src/components/common/ErrorBoundary.js
Normal file
32
src/components/common/ErrorBoundary.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable no-console */
|
||||
import { ErrorBoundary as Boundary } from 'react-error-boundary';
|
||||
import { Button } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './ErrorBoundry.module.css';
|
||||
|
||||
const logError = (error, info) => {
|
||||
console.error(error, info.componentStack);
|
||||
};
|
||||
|
||||
export function ErrorBoundary({ children }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
const fallbackRender = ({ error, resetErrorBoundary }) => {
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<h1>{formatMessage(messages.error)}</h1>
|
||||
<h3>{error.message}</h3>
|
||||
<pre>{error.stack}</pre>
|
||||
<Button onClick={resetErrorBoundary}>OK</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Boundary fallbackRender={fallbackRender} onError={logError}>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
19
src/components/common/ErrorBoundry.module.css
Normal file
19
src/components/common/ErrorBoundry.module.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.error {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: var(--z-index-overlay);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.error button {
|
||||
align-self: center;
|
||||
}
|
||||
18
src/components/common/ErrorMessage.js
Normal file
18
src/components/common/ErrorMessage.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Icon, Icons, Text } from 'react-basics';
|
||||
import styles from './ErrorMessage.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function ErrorMessage() {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<Icon className={styles.icon} size="large">
|
||||
<Icons.Alert />
|
||||
</Icon>
|
||||
<Text>{formatMessage(messages.error)}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorMessage;
|
||||
15
src/components/common/ErrorMessage.module.css
Normal file
15
src/components/common/ErrorMessage.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: auto;
|
||||
display: flex;
|
||||
background-color: var(--base50);
|
||||
padding: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
22
src/components/common/Favicon.js
Normal file
22
src/components/common/Favicon.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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 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;
|
||||
}
|
||||
|
||||
export default Favicon;
|
||||
3
src/components/common/Favicon.module.css
Normal file
3
src/components/common/Favicon.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.favicon {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
13
src/components/common/FilterButtons.js
Normal file
13
src/components/common/FilterButtons.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ButtonGroup, Button, Flexbox } from 'react-basics';
|
||||
|
||||
export function FilterButtons({ items, selectedKey, onSelect }) {
|
||||
return (
|
||||
<Flexbox justifyContent="center">
|
||||
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
|
||||
{({ key, label }) => <Button key={key}>{label}</Button>}
|
||||
</ButtonGroup>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterButtons;
|
||||
40
src/components/common/FilterLink.js
Normal file
40
src/components/common/FilterLink.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Icon, Icons } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import Link from 'next/link';
|
||||
import { safeDecodeURI } from 'next-basics';
|
||||
import usePageQuery from 'components/hooks/usePageQuery';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './FilterLink.module.css';
|
||||
|
||||
export function FilterLink({ id, value, label, externalUrl, children, className }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { resolveUrl, query } = usePageQuery();
|
||||
const active = query[id] !== undefined;
|
||||
const selected = query[id] === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.row, className, {
|
||||
[styles.inactive]: active && !selected,
|
||||
[styles.active]: active && selected,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
{!value && `(${label || formatMessage(labels.unknown)})`}
|
||||
{value && (
|
||||
<Link href={resolveUrl({ [id]: value })} className={styles.label} replace>
|
||||
{safeDecodeURI(label || value)}
|
||||
</Link>
|
||||
)}
|
||||
{externalUrl && (
|
||||
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
|
||||
<Icon className={styles.icon}>
|
||||
<Icons.External />
|
||||
</Icon>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterLink;
|
||||
37
src/components/common/FilterLink.module.css
Normal file
37
src/components/common/FilterLink.module.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row.inactive {
|
||||
color: var(--base500);
|
||||
}
|
||||
|
||||
.row.inactive img {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.row.active {
|
||||
color: var(--base900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row .link {
|
||||
display: none;
|
||||
margin-inline-start: 20px;
|
||||
}
|
||||
|
||||
.row .label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row:hover .link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
60
src/components/common/HamburgerButton.js
Normal file
60
src/components/common/HamburgerButton.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Button, Icon } from 'react-basics';
|
||||
import { useState } from 'react';
|
||||
import MobileMenu from './MobileMenu';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useConfig from 'components/hooks/useConfig';
|
||||
|
||||
export function HamburgerButton() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [active, setActive] = useState(false);
|
||||
const { cloudMode } = useConfig();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: formatMessage(labels.dashboard),
|
||||
url: '/dashboard',
|
||||
},
|
||||
!cloudMode && {
|
||||
label: formatMessage(labels.settings),
|
||||
url: '/settings',
|
||||
children: [
|
||||
{
|
||||
label: formatMessage(labels.websites),
|
||||
url: '/settings/websites',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.teams),
|
||||
url: '/settings/teams',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.users),
|
||||
url: '/settings/users',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.profile),
|
||||
url: '/settings/profile',
|
||||
},
|
||||
],
|
||||
},
|
||||
cloudMode && {
|
||||
label: formatMessage(labels.profile),
|
||||
url: '/settings/profile',
|
||||
},
|
||||
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
|
||||
].filter(n => n);
|
||||
|
||||
const handleClick = () => setActive(state => !state);
|
||||
const handleClose = () => setActive(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="quiet" onClick={handleClick}>
|
||||
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
|
||||
</Button>
|
||||
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default HamburgerButton;
|
||||
9
src/components/common/HamburgerButton.module.css
Normal file
9
src/components/common/HamburgerButton.module.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.button {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
27
src/components/common/HoverTooltip.js
Normal file
27
src/components/common/HoverTooltip.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'react-basics';
|
||||
import styles from './HoverTooltip.module.css';
|
||||
|
||||
export function HoverTooltip({ children }) {
|
||||
const [position, setPosition] = useState({ x: -1000, y: -1000 });
|
||||
|
||||
useEffect(() => {
|
||||
const handler = e => {
|
||||
setPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip className={styles.tooltip} style={{ left: position.x, top: position.y }}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default HoverTooltip;
|
||||
6
src/components/common/HoverTooltip.module.css
Normal file
6
src/components/common/HoverTooltip.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-popup);
|
||||
transform: translate(-50%, calc(-100% - 5px));
|
||||
}
|
||||
14
src/components/common/LinkButton.js
Normal file
14
src/components/common/LinkButton.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import Link from 'next/link';
|
||||
import { Icon, Icons, Text } from 'react-basics';
|
||||
import styles from './LinkButton.module.css';
|
||||
|
||||
export function LinkButton({ href, icon, children }) {
|
||||
return (
|
||||
<Link className={styles.button} href={href}>
|
||||
<Icon>{icon || <Icons.ArrowRight />}</Icon>
|
||||
<Text>{children}</Text>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkButton;
|
||||
28
src/components/common/LinkButton.module.css
Normal file
28
src/components/common/LinkButton.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
gap: var(--size200);
|
||||
font-family: inherit;
|
||||
color: var(--base900);
|
||||
background: var(--base100);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
min-height: var(--base-height);
|
||||
padding: 0 var(--size600);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--base200);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
background: var(--base300);
|
||||
}
|
||||
|
||||
.button:visited {
|
||||
color: var(--base900);
|
||||
}
|
||||
38
src/components/common/MobileMenu.js
Normal file
38
src/components/common/MobileMenu.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import styles from './MobileMenu.module.css';
|
||||
|
||||
export function MobileMenu({ items = [], onClose }) {
|
||||
const { pathname } = useRouter();
|
||||
|
||||
const Items = ({ items, className }) => (
|
||||
<div className={classNames(styles.items, className)}>
|
||||
{items.map(({ label, url, children }) => {
|
||||
const selected = pathname.startsWith(url);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
key={url}
|
||||
href={url}
|
||||
className={classNames(styles.item, { [styles.selected]: selected })}
|
||||
onClick={onClose}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
{children && <Items items={children} className={styles.submenu} />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.menu)}>
|
||||
<Items items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileMenu;
|
||||
39
src/components/common/MobileMenu.module.css
Normal file
39
src/components/common/MobileMenu.module.css
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
.menu {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--base50);
|
||||
z-index: var(--z-index-popup);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
line-height: 80px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
a.item {
|
||||
color: var(--base600);
|
||||
}
|
||||
|
||||
a.item.selected,
|
||||
.submenu a.item.selected {
|
||||
color: var(--base900);
|
||||
}
|
||||
|
||||
.submenu a.item {
|
||||
color: var(--base600);
|
||||
margin-left: 40px;
|
||||
}
|
||||
45
src/components/common/Pager.js
Normal file
45
src/components/common/Pager.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import styles from './Pager.module.css';
|
||||
import { Button, Flexbox, Icon, Icons } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function Pager({ page, pageSize, count, onPageChange }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const maxPage = Math.ceil(count / pageSize);
|
||||
const lastPage = page === maxPage;
|
||||
const firstPage = page === 1;
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePageChange = value => {
|
||||
const nextPage = page + value;
|
||||
if (nextPage > 0 && nextPage <= maxPage) {
|
||||
onPageChange(nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
if (maxPage === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox justifyContent="center" className={styles.container}>
|
||||
<Button onClick={() => handlePageChange(-1)} disabled={firstPage}>
|
||||
<Icon rotate={90}>
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Flexbox alignItems="center" className={styles.text}>
|
||||
{formatMessage(labels.pageOf, { current: page, total: maxPage })}
|
||||
</Flexbox>
|
||||
<Button onClick={() => handlePageChange(1)} disabled={lastPage}>
|
||||
<Icon rotate={270}>
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pager;
|
||||
7
src/components/common/Pager.module.css
Normal file
7
src/components/common/Pager.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 16px;
|
||||
}
|
||||
100
src/components/common/SettingsTable.js
Normal file
100
src/components/common/SettingsTable.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import Empty from 'components/common/Empty';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
SearchField,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'react-basics';
|
||||
import styles from './SettingsTable.module.css';
|
||||
import Pager from 'components/common/Pager';
|
||||
|
||||
export function SettingsTable({
|
||||
columns = [],
|
||||
data,
|
||||
children,
|
||||
cellRender,
|
||||
showSearch,
|
||||
showPaging,
|
||||
onFilterChange,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
filterValue,
|
||||
}) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
const [filter, setFilter] = useState(filterValue);
|
||||
const { data: value, page, count, pageSize } = data;
|
||||
|
||||
const handleFilterChange = value => {
|
||||
setFilter(value);
|
||||
onFilterChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && !!value.length && (
|
||||
<SearchField
|
||||
onChange={handleFilterChange}
|
||||
delay={1000}
|
||||
value={filter}
|
||||
autoFocus={true}
|
||||
placeholder="Search"
|
||||
style={{ maxWidth: '300px', marginBottom: '10px' }}
|
||||
/>
|
||||
)}
|
||||
{value.length === 0 && filterValue && (
|
||||
<Empty message={formatMessage(messages.noResultsFound)} />
|
||||
)}
|
||||
{value.length > 0 && (
|
||||
<Table columns={columns} rows={value}>
|
||||
<TableHeader className={styles.header}>
|
||||
{(column, index) => {
|
||||
return (
|
||||
<TableColumn key={index} className={styles.cell} style={columns[index].style}>
|
||||
{column.label}
|
||||
</TableColumn>
|
||||
);
|
||||
}}
|
||||
</TableHeader>
|
||||
<TableBody className={styles.body}>
|
||||
{(row, keys, rowIndex) => {
|
||||
row.action = children(row, keys, rowIndex);
|
||||
|
||||
return (
|
||||
<TableRow key={rowIndex} data={row} keys={keys} className={styles.row}>
|
||||
{(data, key, colIndex) => {
|
||||
return (
|
||||
<TableCell
|
||||
key={colIndex}
|
||||
className={styles.cell}
|
||||
style={columns[colIndex].style}
|
||||
>
|
||||
<label className={styles.label}>{columns[colIndex].label}</label>
|
||||
{cellRender ? cellRender(row, data, key, colIndex) : data[key]}
|
||||
</TableCell>
|
||||
);
|
||||
}}
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</TableBody>
|
||||
{showPaging && (
|
||||
<Pager
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
count={count}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsTable;
|
||||
44
src/components/common/SettingsTable.module.css
Normal file
44
src/components/common/SettingsTable.module.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.cell {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row .cell:last-child {
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.header .cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.row .cell {
|
||||
padding-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header .cell:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row .cell:last-child {
|
||||
padding-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
62
src/components/common/UpdateNotice.js
Normal file
62
src/components/common/UpdateNotice.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { Button, Row, Column } from 'react-basics';
|
||||
import { setItem } from 'next-basics';
|
||||
import useStore, { checkVersion } from 'store/version';
|
||||
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export function UpdateNotice({ user, config }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||
const { pathname } = useRouter();
|
||||
const [dismissed, setDismissed] = useState(checked);
|
||||
const allowUpdate =
|
||||
user?.isAdmin &&
|
||||
!config?.updatesDisabled &&
|
||||
!config?.cloudMode &&
|
||||
!pathname.includes('/share/') &&
|
||||
!dismissed;
|
||||
|
||||
const updateCheck = useCallback(() => {
|
||||
setItem(VERSION_CHECK, { version: latest, time: Date.now() });
|
||||
}, [latest]);
|
||||
|
||||
function handleViewClick() {
|
||||
updateCheck();
|
||||
setDismissed(true);
|
||||
open(releaseUrl || REPO_URL, '_blank');
|
||||
}
|
||||
|
||||
function handleDismissClick() {
|
||||
updateCheck();
|
||||
setDismissed(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowUpdate) {
|
||||
checkVersion();
|
||||
}
|
||||
}, [allowUpdate]);
|
||||
|
||||
if (!allowUpdate || !hasUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className={styles.notice}>
|
||||
<Column variant="two" className={styles.message}>
|
||||
{formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}
|
||||
</Column>
|
||||
<Column className={styles.buttons}>
|
||||
<Button variant="primary" onClick={handleViewClick}>
|
||||
{formatMessage(labels.viewDetails)}
|
||||
</Button>
|
||||
<Button onClick={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateNotice;
|
||||
34
src/components/common/UpdateNotice.module.css
Normal file
34
src/components/common/UpdateNotice.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
.notice {
|
||||
position: absolute;
|
||||
max-width: 800px;
|
||||
gap: 20px;
|
||||
margin: 20px auto;
|
||||
justify-self: center;
|
||||
background: var(--base50);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--base300);
|
||||
border-radius: var(--border-radius);
|
||||
z-index: var(--z-index-popup);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--font-color100);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.message {
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
85
src/components/common/WorldMap.js
Normal file
85
src/components/common/WorldMap.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import classNames from 'classnames';
|
||||
import { colord } from 'colord';
|
||||
import HoverTooltip from 'components/common/HoverTooltip';
|
||||
import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
import useCountryNames from 'components/hooks/useCountryNames';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import styles from './WorldMap.module.css';
|
||||
|
||||
export function WorldMap({ data, className }) {
|
||||
const { basePath } = useRouter();
|
||||
const [tooltip, setTooltipPopup] = useState();
|
||||
const { theme, colors } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
|
||||
|
||||
function getFillColor(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = metrics?.find(({ x }) => x === code);
|
||||
|
||||
if (!country) {
|
||||
return colors.map.fillColor;
|
||||
}
|
||||
|
||||
return colord(colors.map.baseColor)
|
||||
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
|
||||
.toHex();
|
||||
}
|
||||
|
||||
function getOpacity(code) {
|
||||
return code === 'AQ' ? 0 : 1;
|
||||
}
|
||||
|
||||
function handleHover(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = metrics?.find(({ x }) => x === code);
|
||||
setTooltipPopup(`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} visitors`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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={`${basePath}${MAP_FILE}`}>
|
||||
{({ geographies }) => {
|
||||
return geographies.map(geo => {
|
||||
const code = ISO_COUNTRIES[geo.id];
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={getFillColor(code)}
|
||||
stroke={colors.map.strokeColor}
|
||||
opacity={getOpacity(code)}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: colors.map.hoverColor },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseOver={() => handleHover(code)}
|
||||
onMouseOut={() => setTooltipPopup(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
{tooltip && <HoverTooltip>{tooltip}</HoverTooltip>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorldMap;
|
||||
4
src/components/common/WorldMap.module.css
Normal file
4
src/components/common/WorldMap.module.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
2
src/components/declarations.d.ts
vendored
Normal file
2
src/components/declarations.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare module '*.css';
|
||||
declare module '*.svg';
|
||||
23
src/components/hooks/index.js
Normal file
23
src/components/hooks/index.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export * from './useApi';
|
||||
export * from './useConfig';
|
||||
export * from './useCountryNames';
|
||||
export * from './useDateRange';
|
||||
export * from './useDocumentClick';
|
||||
export * from './useEscapeKey';
|
||||
export * from './useFilters';
|
||||
export * from './useForceUpdate';
|
||||
export * from './useFormat';
|
||||
export * from './useLanguageNames';
|
||||
export * from './useLocale';
|
||||
export * from './useMessages';
|
||||
export * from './usePageQuery';
|
||||
export * from './useReport';
|
||||
export * from './useReports';
|
||||
export * from './useRequireLogin';
|
||||
export * from './useShareToken';
|
||||
export * from './useSticky';
|
||||
export * from './useTheme';
|
||||
export * from './useTimezone';
|
||||
export * from './useUser';
|
||||
export * from './useWebsite';
|
||||
export * from './useWebsiteReports';
|
||||
22
src/components/hooks/useApi.ts
Normal file
22
src/components/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import * as reactQuery from '@tanstack/react-query';
|
||||
import { useApi as nextUseApi } from 'next-basics';
|
||||
import { getClientAuthToken } from 'lib/client';
|
||||
import { SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||
import useStore from 'store/app';
|
||||
|
||||
const selector = state => state.shareToken;
|
||||
|
||||
export function useApi() {
|
||||
const { basePath } = useRouter();
|
||||
const shareToken = useStore(selector);
|
||||
|
||||
const { get, post, put, del } = nextUseApi(
|
||||
{ authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token },
|
||||
basePath,
|
||||
);
|
||||
|
||||
return { get, post, put, del, ...reactQuery };
|
||||
}
|
||||
|
||||
export default useApi;
|
||||
28
src/components/hooks/useApiFilter.ts
Normal file
28
src/components/hooks/useApiFilter.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export function useApiFilter() {
|
||||
const [filter, setFilter] = useState();
|
||||
const [filterType, setFilterType] = useState('All');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const handleFilterChange = value => setFilter(value);
|
||||
const handlePageChange = value => setPage(value);
|
||||
const handlePageSizeChange = value => setPageSize(value);
|
||||
|
||||
return {
|
||||
filter,
|
||||
setFilter,
|
||||
filterType,
|
||||
setFilterType,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useApiFilter;
|
||||
27
src/components/hooks/useConfig.js
Normal file
27
src/components/hooks/useConfig.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect } from 'react';
|
||||
import useStore, { setConfig } from 'store/app';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
|
||||
let loading = false;
|
||||
|
||||
export function useConfig() {
|
||||
const { config } = useStore();
|
||||
const { get } = useApi();
|
||||
|
||||
async function loadConfig() {
|
||||
const data = await get('/config');
|
||||
loading = false;
|
||||
setConfig(data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!config && !loading) {
|
||||
loading = true;
|
||||
loadConfig();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export default useConfig;
|
||||
36
src/components/hooks/useCountryNames.js
Normal file
36
src/components/hooks/useCountryNames.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet } from 'next-basics';
|
||||
import enUS from 'public/intl/country/en-US.json';
|
||||
|
||||
const countryNames = {
|
||||
'en-US': enUS,
|
||||
};
|
||||
|
||||
export function useCountryNames(locale) {
|
||||
const [list, setList] = useState(countryNames[locale] || enUS);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
async function loadData(locale) {
|
||||
const { data } = await httpGet(`${basePath}/intl/country/${locale}.json`);
|
||||
|
||||
if (data) {
|
||||
countryNames[locale] = data;
|
||||
setList(countryNames[locale]);
|
||||
} else {
|
||||
setList(enUS);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!countryNames[locale]) {
|
||||
loadData(locale);
|
||||
} else {
|
||||
setList(countryNames[locale]);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export default useCountryNames;
|
||||
50
src/components/hooks/useDateRange.js
Normal file
50
src/components/hooks/useDateRange.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { getMinimumUnit, parseDateRange } from 'lib/date';
|
||||
import { setItem } from 'next-basics';
|
||||
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
|
||||
import useLocale from './useLocale';
|
||||
import websiteStore, { setWebsiteDateRange } from 'store/websites';
|
||||
import appStore, { setDateRange } from 'store/app';
|
||||
import useApi from './useApi';
|
||||
|
||||
export function useDateRange(websiteId) {
|
||||
const { get } = useApi();
|
||||
const { locale } = useLocale();
|
||||
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
|
||||
const defaultConfig = DEFAULT_DATE_RANGE;
|
||||
const globalConfig = appStore(state => state.dateRange);
|
||||
const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale);
|
||||
|
||||
const saveDateRange = async value => {
|
||||
if (websiteId) {
|
||||
let dateRange = value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value === 'all') {
|
||||
const result = await get(`/websites/${websiteId}/daterange`);
|
||||
const { mindate, maxdate } = result;
|
||||
|
||||
const startDate = new Date(mindate);
|
||||
const endDate = new Date(maxdate);
|
||||
|
||||
dateRange = {
|
||||
startDate,
|
||||
endDate,
|
||||
unit: getMinimumUnit(startDate, endDate),
|
||||
value,
|
||||
};
|
||||
} else {
|
||||
dateRange = parseDateRange(value, locale);
|
||||
}
|
||||
}
|
||||
|
||||
setWebsiteDateRange(websiteId, dateRange);
|
||||
} else {
|
||||
setItem(DATE_RANGE_CONFIG, value);
|
||||
setDateRange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return [dateRange, saveDateRange];
|
||||
}
|
||||
|
||||
export default useDateRange;
|
||||
15
src/components/hooks/useDocumentClick.js
Normal file
15
src/components/hooks/useDocumentClick.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
export function useDocumentClick(handler) {
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handler);
|
||||
};
|
||||
}, [handler]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default useDocumentClick;
|
||||
21
src/components/hooks/useEscapeKey.js
Normal file
21
src/components/hooks/useEscapeKey.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
export function useEscapeKey(handler) {
|
||||
const escFunction = useCallback(event => {
|
||||
if (event.keyCode === 27) {
|
||||
handler(event);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', escFunction, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', escFunction, false);
|
||||
};
|
||||
}, [escFunction]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default useEscapeKey;
|
||||
47
src/components/hooks/useFilters.js
Normal file
47
src/components/hooks/useFilters.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useMessages } from './useMessages';
|
||||
import { OPERATORS } from 'lib/constants';
|
||||
|
||||
export function useFilters() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const filterLabels = {
|
||||
[OPERATORS.equals]: formatMessage(labels.is),
|
||||
[OPERATORS.notEquals]: formatMessage(labels.isNot),
|
||||
[OPERATORS.set]: formatMessage(labels.isSet),
|
||||
[OPERATORS.notSet]: formatMessage(labels.isNotSet),
|
||||
[OPERATORS.contains]: formatMessage(labels.contains),
|
||||
[OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain),
|
||||
[OPERATORS.true]: formatMessage(labels.true),
|
||||
[OPERATORS.false]: formatMessage(labels.false),
|
||||
[OPERATORS.greaterThan]: formatMessage(labels.greaterThan),
|
||||
[OPERATORS.lessThan]: formatMessage(labels.lessThan),
|
||||
[OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals),
|
||||
[OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals),
|
||||
[OPERATORS.before]: formatMessage(labels.before),
|
||||
[OPERATORS.after]: formatMessage(labels.after),
|
||||
};
|
||||
|
||||
const typeFilters = {
|
||||
string: [OPERATORS.equals, OPERATORS.notEquals],
|
||||
array: [OPERATORS.contains, OPERATORS.doesNotContain],
|
||||
boolean: [OPERATORS.true, OPERATORS.false],
|
||||
number: [
|
||||
OPERATORS.equals,
|
||||
OPERATORS.notEquals,
|
||||
OPERATORS.greaterThan,
|
||||
OPERATORS.lessThan,
|
||||
OPERATORS.greaterThanEquals,
|
||||
OPERATORS.lessThanEquals,
|
||||
],
|
||||
date: [OPERATORS.before, OPERATORS.after],
|
||||
uuid: [OPERATORS.equals],
|
||||
};
|
||||
|
||||
const getFilters = type => {
|
||||
return typeFilters[type]?.map(key => ({ type, value: key, label: filterLabels[key] })) ?? [];
|
||||
};
|
||||
|
||||
return { getFilters, filterLabels, typeFilters };
|
||||
}
|
||||
|
||||
export default useFilters;
|
||||
11
src/components/hooks/useForceUpdate.js
Normal file
11
src/components/hooks/useForceUpdate.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
export function useForceUpdate() {
|
||||
const [, update] = useState(Object.create(null));
|
||||
|
||||
return useCallback(() => {
|
||||
update(Object.create(null));
|
||||
}, [update]);
|
||||
}
|
||||
|
||||
export default useForceUpdate;
|
||||
39
src/components/hooks/useFormat.js
Normal file
39
src/components/hooks/useFormat.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import useMessages from './useMessages';
|
||||
import { BROWSERS } from 'lib/constants';
|
||||
import useLocale from './useLocale';
|
||||
import useCountryNames from './useCountryNames';
|
||||
|
||||
export function useFormat() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
|
||||
const formatBrowser = value => {
|
||||
return BROWSERS[value] || value;
|
||||
};
|
||||
|
||||
const formatCountry = value => {
|
||||
return countryNames[value] || value;
|
||||
};
|
||||
|
||||
const formatDevice = value => {
|
||||
return formatMessage(labels[value] || labels.unknown);
|
||||
};
|
||||
|
||||
const formatValue = (value, type) => {
|
||||
switch (type) {
|
||||
case 'browser':
|
||||
return formatBrowser(value);
|
||||
case 'country':
|
||||
return formatCountry(value);
|
||||
case 'device':
|
||||
return formatDevice(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
return { formatBrowser, formatCountry, formatDevice, formatValue };
|
||||
}
|
||||
|
||||
export default useFormat;
|
||||
36
src/components/hooks/useLanguageNames.js
Normal file
36
src/components/hooks/useLanguageNames.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet } from 'next-basics';
|
||||
import enUS from 'public/intl/language/en-US.json';
|
||||
|
||||
const languageNames = {
|
||||
'en-US': enUS,
|
||||
};
|
||||
|
||||
export function useLanguageNames(locale) {
|
||||
const [list, setList] = useState(languageNames[locale] || enUS);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
async function loadData(locale) {
|
||||
const { data } = await httpGet(`${basePath}/intl/language/${locale}.json`);
|
||||
|
||||
if (data) {
|
||||
languageNames[locale] = data;
|
||||
setList(languageNames[locale]);
|
||||
} else {
|
||||
setList(enUS);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageNames[locale]) {
|
||||
loadData(locale);
|
||||
} else {
|
||||
setList(languageNames[locale]);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export default useLanguageNames;
|
||||
65
src/components/hooks/useLocale.js
Normal file
65
src/components/hooks/useLocale.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet, setItem } from 'next-basics';
|
||||
import { LOCALE_CONFIG } from 'lib/constants';
|
||||
import { getDateLocale, getTextDirection } from 'lib/lang';
|
||||
import useStore, { setLocale } from 'store/app';
|
||||
import useForceUpdate from 'components/hooks/useForceUpdate';
|
||||
import enUS from 'public/intl/messages/en-US.json';
|
||||
|
||||
const messages = {
|
||||
'en-US': enUS,
|
||||
};
|
||||
|
||||
const selector = state => state.locale;
|
||||
|
||||
export function useLocale() {
|
||||
const locale = useStore(selector);
|
||||
const { basePath } = useRouter();
|
||||
const forceUpdate = useForceUpdate();
|
||||
const dir = getTextDirection(locale);
|
||||
const dateLocale = getDateLocale(locale);
|
||||
|
||||
async function loadMessages(locale) {
|
||||
const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`);
|
||||
|
||||
if (ok) {
|
||||
messages[locale] = data;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocale(value) {
|
||||
if (!messages[value]) {
|
||||
await loadMessages(value);
|
||||
}
|
||||
|
||||
setItem(LOCALE_CONFIG, value);
|
||||
|
||||
document.getElementById('__next')?.setAttribute('dir', getTextDirection(value));
|
||||
|
||||
if (locale !== value) {
|
||||
setLocale(value);
|
||||
} else {
|
||||
forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!messages[locale]) {
|
||||
saveLocale(locale);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window?.location?.href);
|
||||
const locale = url.searchParams.get('locale');
|
||||
|
||||
if (locale) {
|
||||
saveLocale(locale);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { locale, saveLocale, messages, dir, dateLocale };
|
||||
}
|
||||
|
||||
export default useLocale;
|
||||
20
src/components/hooks/useMessages.js
Normal file
20
src/components/hooks/useMessages.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useIntl, FormattedMessage } from 'react-intl';
|
||||
import { messages, labels } from 'components/messages';
|
||||
|
||||
export function useMessages() {
|
||||
const intl = useIntl();
|
||||
|
||||
const getMessage = id => {
|
||||
const message = Object.values(messages).find(value => value.id === id);
|
||||
|
||||
return message ? formatMessage(message) : id;
|
||||
};
|
||||
|
||||
const formatMessage = (descriptor, values, opts) => {
|
||||
return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
|
||||
};
|
||||
|
||||
return { formatMessage, FormattedMessage, messages, labels, getMessage };
|
||||
}
|
||||
|
||||
export default useMessages;
|
||||
33
src/components/hooks/usePageQuery.js
Normal file
33
src/components/hooks/usePageQuery.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { buildUrl } from 'next-basics';
|
||||
|
||||
export function usePageQuery() {
|
||||
const router = useRouter();
|
||||
const { pathname, search } = location;
|
||||
const { asPath } = router;
|
||||
|
||||
const query = useMemo(() => {
|
||||
if (!search) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = search.substring(1).split('&');
|
||||
|
||||
return params.reduce((obj, item) => {
|
||||
const [key, value] = item.split('=');
|
||||
|
||||
obj[key] = decodeURIComponent(value);
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}, [search]);
|
||||
|
||||
function resolveUrl(params, reset) {
|
||||
return buildUrl(asPath.split('?')[0], { ...(reset ? {} : query), ...params });
|
||||
}
|
||||
|
||||
return { pathname, query, resolveUrl, router };
|
||||
}
|
||||
|
||||
export default usePageQuery;
|
||||
86
src/components/hooks/useReport.js
Normal file
86
src/components/hooks/useReport.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { produce } from 'immer';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTimezone } from './useTimezone';
|
||||
import useApi from './useApi';
|
||||
|
||||
const baseParameters = {
|
||||
name: 'Untitled',
|
||||
description: '',
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
export function useReport(reportId, defaultParameters) {
|
||||
const [report, setReport] = useState(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const { get, post } = useApi();
|
||||
const [timezone] = useTimezone();
|
||||
|
||||
const loadReport = async id => {
|
||||
const data = await get(`/reports/${id}`);
|
||||
|
||||
const { dateRange } = data?.parameters || {};
|
||||
const { startDate, endDate } = dateRange || {};
|
||||
|
||||
if (startDate && endDate) {
|
||||
dateRange.startDate = new Date(startDate);
|
||||
dateRange.endDate = new Date(endDate);
|
||||
}
|
||||
|
||||
setReport(data);
|
||||
};
|
||||
|
||||
const runReport = useCallback(
|
||||
async parameters => {
|
||||
setIsRunning(true);
|
||||
|
||||
const { type } = report;
|
||||
|
||||
const data = await post(`/reports/${type}`, { ...parameters, timezone });
|
||||
|
||||
setReport(
|
||||
produce(state => {
|
||||
state.parameters = parameters;
|
||||
state.data = data;
|
||||
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
|
||||
setIsRunning(false);
|
||||
},
|
||||
[report],
|
||||
);
|
||||
|
||||
const updateReport = useCallback(
|
||||
async data => {
|
||||
setReport(
|
||||
produce(state => {
|
||||
const { parameters, ...rest } = data;
|
||||
|
||||
if (parameters) {
|
||||
state.parameters = { ...state.parameters, ...parameters };
|
||||
}
|
||||
|
||||
for (const key in rest) {
|
||||
state[key] = rest[key];
|
||||
}
|
||||
|
||||
return state;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[report],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) {
|
||||
setReport({ ...baseParameters, ...defaultParameters });
|
||||
} else {
|
||||
loadReport(reportId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { report, runReport, updateReport, isRunning };
|
||||
}
|
||||
|
||||
export default useReport;
|
||||
38
src/components/hooks/useReports.js
Normal file
38
src/components/hooks/useReports.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState } from 'react';
|
||||
import useApi from './useApi';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
|
||||
export function useReports() {
|
||||
const [modified, setModified] = useState(Date.now());
|
||||
const { get, useQuery, del, useMutation } = useApi();
|
||||
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
||||
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||
useApiFilter();
|
||||
const { data, error, isLoading } = useQuery(
|
||||
['reports', { modified, filter, page, pageSize }],
|
||||
() => get(`/reports`, { filter, page, pageSize }),
|
||||
);
|
||||
|
||||
const deleteReport = id => {
|
||||
mutate(id, {
|
||||
onSuccess: () => {
|
||||
setModified(Date.now());
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
reports: data,
|
||||
error,
|
||||
isLoading,
|
||||
deleteReport,
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useReports;
|
||||
30
src/components/hooks/useRequireLogin.js
Normal file
30
src/components/hooks/useRequireLogin.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
|
||||
export function useRequireLogin() {
|
||||
const router = useRouter();
|
||||
const { get } = useApi();
|
||||
const { user, setUser } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUser() {
|
||||
try {
|
||||
const { user } = await get('/auth/verify');
|
||||
|
||||
setUser(user);
|
||||
} catch {
|
||||
await router.push('/login');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
loadUser();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return { user };
|
||||
}
|
||||
|
||||
export default useRequireLogin;
|
||||
28
src/components/hooks/useShareToken.js
Normal file
28
src/components/hooks/useShareToken.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useEffect } from 'react';
|
||||
import useStore, { setShareToken } from 'store/app';
|
||||
import useApi from './useApi';
|
||||
|
||||
const selector = state => state.shareToken;
|
||||
|
||||
export function useShareToken(shareId) {
|
||||
const shareToken = useStore(selector);
|
||||
const { get } = useApi();
|
||||
|
||||
async function loadToken(id) {
|
||||
const data = await get(`/share/${id}`);
|
||||
|
||||
if (data) {
|
||||
setShareToken(data);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (shareId) {
|
||||
loadToken(shareId);
|
||||
}
|
||||
}, [shareId]);
|
||||
|
||||
return shareToken;
|
||||
}
|
||||
|
||||
export default useShareToken;
|
||||
25
src/components/hooks/useSticky.js
Normal file
25
src/components/hooks/useSticky.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useSticky({ enabled = true, threshold = 1 }) {
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let observer;
|
||||
const handler = ([entry]) => setIsSticky(entry.intersectionRatio < threshold);
|
||||
|
||||
if (enabled && ref.current) {
|
||||
observer = new IntersectionObserver(handler, { threshold: [threshold] });
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}, [ref, enabled, threshold]);
|
||||
|
||||
return { ref, isSticky };
|
||||
}
|
||||
|
||||
export default useSticky;
|
||||
68
src/components/hooks/useTheme.js
Normal file
68
src/components/hooks/useTheme.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect } from 'react';
|
||||
import useStore, { setTheme } from 'store/app';
|
||||
import { getItem, setItem } from 'next-basics';
|
||||
import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
|
||||
import { colord } from 'colord';
|
||||
|
||||
const selector = state => state.theme;
|
||||
|
||||
export function useTheme() {
|
||||
const defaultTheme =
|
||||
typeof window !== 'undefined'
|
||||
? window?.matchMedia('(prefers-color-scheme: dark)')?.matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: 'light';
|
||||
const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme;
|
||||
const primaryColor = colord(THEME_COLORS[theme].primary);
|
||||
|
||||
const colors = {
|
||||
theme: {
|
||||
...THEME_COLORS[theme],
|
||||
},
|
||||
chart: {
|
||||
text: THEME_COLORS[theme].gray700,
|
||||
line: THEME_COLORS[theme].gray200,
|
||||
views: {
|
||||
hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
|
||||
backgroundColor: primaryColor.alpha(0.4).toRgbString(),
|
||||
borderColor: primaryColor.alpha(0.7).toRgbString(),
|
||||
hoverBorderColor: primaryColor.toRgbString(),
|
||||
},
|
||||
visitors: {
|
||||
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
|
||||
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
|
||||
borderColor: primaryColor.alpha(0.9).toRgbString(),
|
||||
hoverBorderColor: primaryColor.toRgbString(),
|
||||
},
|
||||
},
|
||||
map: {
|
||||
baseColor: THEME_COLORS[theme].primary,
|
||||
fillColor: THEME_COLORS[theme].gray100,
|
||||
strokeColor: THEME_COLORS[theme].primary,
|
||||
hoverColor: THEME_COLORS[theme].primary,
|
||||
},
|
||||
};
|
||||
|
||||
function saveTheme(value) {
|
||||
setItem(THEME_CONFIG, value);
|
||||
setTheme(value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window?.location?.href);
|
||||
const theme = url.searchParams.get('theme');
|
||||
|
||||
if (['light', 'dark'].includes(theme)) {
|
||||
saveTheme(theme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { theme, saveTheme, colors };
|
||||
}
|
||||
|
||||
export default useTheme;
|
||||
20
src/components/hooks/useTimezone.js
Normal file
20
src/components/hooks/useTimezone.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { getTimezone } from 'lib/date';
|
||||
import { getItem, setItem } from 'next-basics';
|
||||
import { TIMEZONE_CONFIG } from 'lib/constants';
|
||||
|
||||
export function useTimezone() {
|
||||
const [timezone, setTimezone] = useState(getItem(TIMEZONE_CONFIG) || getTimezone());
|
||||
|
||||
const saveTimezone = useCallback(
|
||||
value => {
|
||||
setItem(TIMEZONE_CONFIG, value);
|
||||
setTimezone(value);
|
||||
},
|
||||
[setTimezone],
|
||||
);
|
||||
|
||||
return [timezone, saveTimezone];
|
||||
}
|
||||
|
||||
export default useTimezone;
|
||||
11
src/components/hooks/useUser.js
Normal file
11
src/components/hooks/useUser.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import useStore, { setUser } from 'store/app';
|
||||
|
||||
const selector = state => state.user;
|
||||
|
||||
export function useUser() {
|
||||
const user = useStore(selector);
|
||||
|
||||
return { user, setUser };
|
||||
}
|
||||
|
||||
export default useUser;
|
||||
10
src/components/hooks/useWebsite.js
Normal file
10
src/components/hooks/useWebsite.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import useApi from './useApi';
|
||||
|
||||
export function useWebsite(websiteId) {
|
||||
const { get, useQuery } = useApi();
|
||||
return useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`), {
|
||||
enabled: !!websiteId,
|
||||
});
|
||||
}
|
||||
|
||||
export default useWebsite;
|
||||
38
src/components/hooks/useWebsiteReports.js
Normal file
38
src/components/hooks/useWebsiteReports.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState } from 'react';
|
||||
import useApi from './useApi';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
|
||||
export function useWebsiteReports(websiteId) {
|
||||
const [modified, setModified] = useState(Date.now());
|
||||
const { get, useQuery, del, useMutation } = useApi();
|
||||
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
||||
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||
useApiFilter();
|
||||
const { data, error, isLoading } = useQuery(
|
||||
['reports:website', { websiteId, modified, filter, page, pageSize }],
|
||||
() => get(`/websites/${websiteId}/reports`, { websiteId, filter, page, pageSize }),
|
||||
);
|
||||
|
||||
const deleteReport = id => {
|
||||
mutate(id, {
|
||||
onSuccess: () => {
|
||||
setModified(Date.now());
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
reports: data,
|
||||
error,
|
||||
isLoading,
|
||||
deleteReport,
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useWebsiteReports;
|
||||
51
src/components/icons.ts
Normal file
51
src/components/icons.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Icons } from 'react-basics';
|
||||
import AddUser from 'assets/add-user.svg';
|
||||
import Bars from 'assets/bars.svg';
|
||||
import BarChart from 'assets/bar-chart.svg';
|
||||
import Bolt from 'assets/bolt.svg';
|
||||
import Calendar from 'assets/calendar.svg';
|
||||
import Clock from 'assets/clock.svg';
|
||||
import Dashboard from 'assets/dashboard.svg';
|
||||
import Eye from 'assets/eye.svg';
|
||||
import Gear from 'assets/gear.svg';
|
||||
import Globe from 'assets/globe.svg';
|
||||
import Lock from 'assets/lock.svg';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import Magnet from 'assets/magnet.svg';
|
||||
import Moon from 'assets/moon.svg';
|
||||
import Nodes from 'assets/nodes.svg';
|
||||
import Overview from 'assets/overview.svg';
|
||||
import Profile from 'assets/profile.svg';
|
||||
import Reports from 'assets/reports.svg';
|
||||
import Sun from 'assets/sun.svg';
|
||||
import User from 'assets/user.svg';
|
||||
import Users from 'assets/users.svg';
|
||||
import Visitor from 'assets/visitor.svg';
|
||||
|
||||
const icons: any = {
|
||||
...Icons,
|
||||
AddUser,
|
||||
Bars,
|
||||
BarChart,
|
||||
Bolt,
|
||||
Calendar,
|
||||
Clock,
|
||||
Dashboard,
|
||||
Eye,
|
||||
Gear,
|
||||
Globe,
|
||||
Lock,
|
||||
Logo,
|
||||
Magnet,
|
||||
Moon,
|
||||
Nodes,
|
||||
Overview,
|
||||
Profile,
|
||||
Reports,
|
||||
Sun,
|
||||
User,
|
||||
Users,
|
||||
Visitor,
|
||||
};
|
||||
|
||||
export default icons;
|
||||
145
src/components/input/DateFilter.js
Normal file
145
src/components/input/DateFilter.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics';
|
||||
import { endOfYear, isSameDay } from 'date-fns';
|
||||
import DatePickerForm from 'components/metrics/DatePickerForm';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import { formatDate } from 'lib/date';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function DateFilter({
|
||||
value,
|
||||
startDate,
|
||||
endDate,
|
||||
className,
|
||||
onChange,
|
||||
showAllTime = false,
|
||||
alignment = 'end',
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const options = [
|
||||
{ label: formatMessage(labels.today), value: '1day' },
|
||||
{
|
||||
label: formatMessage(labels.lastHours, { x: 24 }),
|
||||
value: '24hour',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.yesterday),
|
||||
value: '-1day',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.thisWeek),
|
||||
value: '1week',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.lastDays, { x: 7 }),
|
||||
value: '7day',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.thisMonth),
|
||||
value: '1month',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.lastDays, { x: 30 }),
|
||||
value: '30day',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.lastDays, { x: 90 }),
|
||||
value: '90day',
|
||||
},
|
||||
{ label: formatMessage(labels.thisYear), value: '1year' },
|
||||
showAllTime && {
|
||||
label: formatMessage(labels.allTime),
|
||||
value: 'all',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.customRange),
|
||||
value: 'custom',
|
||||
divider: true,
|
||||
},
|
||||
].filter(n => n);
|
||||
|
||||
const renderValue = value => {
|
||||
return value.startsWith('range') ? (
|
||||
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
|
||||
) : (
|
||||
options.find(e => e.value === value).label
|
||||
);
|
||||
};
|
||||
|
||||
const handleChange = value => {
|
||||
if (value === 'custom') {
|
||||
setShowPicker(true);
|
||||
return;
|
||||
}
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const handlePickerChange = value => {
|
||||
setShowPicker(false);
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const handleClose = () => setShowPicker(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
className={className}
|
||||
items={options}
|
||||
renderValue={renderValue}
|
||||
value={value}
|
||||
alignment={alignment}
|
||||
placeholder={formatMessage(labels.selectDate)}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{({ label, value, divider }) => (
|
||||
<Item key={value} divider={divider}>
|
||||
{label}
|
||||
</Item>
|
||||
)}
|
||||
</Dropdown>
|
||||
{showPicker && (
|
||||
<Modal onClose={handleClose}>
|
||||
<DatePickerForm
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={new Date(2000, 0, 1)}
|
||||
maxDate={endOfYear(new Date())}
|
||||
onChange={handlePickerChange}
|
||||
onClose={() => setShowPicker(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomRange = ({ startDate, endDate, onClick }) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
function handleClick(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick();
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={10} alignItems="center" wrap="nowrap">
|
||||
<Icon className="mr-2" onClick={handleClick}>
|
||||
<Icons.Calendar />
|
||||
</Icon>
|
||||
<Text>
|
||||
{formatDate(startDate, 'd LLL y', locale)}
|
||||
{!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'd LLL y', locale)}`}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateFilter;
|
||||
53
src/components/input/LanguageButton.js
Normal file
53
src/components/input/LanguageButton.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Icon, Button, PopupTrigger, Popup, Text } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import { languages } from 'lib/lang';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './LanguageButton.module.css';
|
||||
|
||||
export function LanguageButton() {
|
||||
const { locale, saveLocale, dir } = useLocale();
|
||||
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
|
||||
|
||||
function handleSelect(value, close, e) {
|
||||
e.stopPropagation();
|
||||
saveLocale(value);
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<Icons.Globe />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
|
||||
{close => {
|
||||
return (
|
||||
<div className={styles.menu}>
|
||||
{items.map(({ value, label }) => {
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.item, { [styles.selected]: value === locale })}
|
||||
onClick={handleSelect.bind(null, value, close)}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
{value === locale && (
|
||||
<Icon className={styles.icon}>
|
||||
<Icons.Check />
|
||||
</Icon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageButton;
|
||||
34
src/components/input/LanguageButton.module.css
Normal file
34
src/components/input/LanguageButton.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
min-width: 640px;
|
||||
padding: 10px;
|
||||
background: var(--base50);
|
||||
z-index: var(--z-index-popup);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: calc(100% / 3);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--base75);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 700;
|
||||
background: var(--blue100);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--primary400);
|
||||
}
|
||||
20
src/components/input/LogoutButton.js
Normal file
20
src/components/input/LogoutButton.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Button, Icon, Icons, TooltipPopup } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function LogoutButton({ tooltipPosition = 'top' }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
return (
|
||||
<Link href="/src/pages/logout">
|
||||
<TooltipPopup label={formatMessage(labels.logout)} position={tooltipPosition}>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<Icons.Logout />
|
||||
</Icon>
|
||||
</Button>
|
||||
</TooltipPopup>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogoutButton;
|
||||
71
src/components/input/MonthSelect.js
Normal file
71
src/components/input/MonthSelect.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useRef } from 'react';
|
||||
import {
|
||||
Text,
|
||||
Icon,
|
||||
CalendarMonthSelect,
|
||||
CalendarYearSelect,
|
||||
Button,
|
||||
PopupTrigger,
|
||||
Popup,
|
||||
} from 'react-basics';
|
||||
import { startOfMonth, endOfMonth } from 'date-fns';
|
||||
import Icons from 'components/icons';
|
||||
import { useLocale } from 'components/hooks';
|
||||
import { formatDate } from 'lib/date';
|
||||
import { getDateLocale } from 'lib/lang';
|
||||
import styles from './MonthSelect.module.css';
|
||||
|
||||
export function MonthSelect({ date = new Date(), onChange }) {
|
||||
const { locale } = useLocale();
|
||||
const month = formatDate(date, 'MMMM', locale);
|
||||
const year = date.getFullYear();
|
||||
const ref = useRef();
|
||||
|
||||
const handleChange = (close, date) => {
|
||||
onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={styles.container}>
|
||||
<PopupTrigger>
|
||||
<Button className={styles.input} variant="quiet">
|
||||
<Text>{month}</Text>
|
||||
<Icon size="sm">
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup className={styles.popup} alignment="start">
|
||||
{close => (
|
||||
<CalendarMonthSelect
|
||||
date={date}
|
||||
locale={getDateLocale(locale)}
|
||||
onSelect={handleChange.bind(null, close)}
|
||||
/>
|
||||
)}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
<PopupTrigger>
|
||||
<Button className={styles.input} variant="quiet">
|
||||
<Text>{year}</Text>
|
||||
<Icon size="sm">
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup className={styles.popup} alignment="start">
|
||||
{close => (
|
||||
<CalendarYearSelect
|
||||
date={date}
|
||||
locale={getDateLocale(locale)}
|
||||
onSelect={handleChange.bind(null, close)}
|
||||
/>
|
||||
)}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonthSelect;
|
||||
22
src/components/input/MonthSelect.module.css
Normal file
22
src/components/input/MonthSelect.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup {
|
||||
border: 1px solid var(--base400);
|
||||
background: var(--base50);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
61
src/components/input/ProfileButton.js
Normal file
61
src/components/input/ProfileButton.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Icon, Button, PopupTrigger, Popup, Menu, Item, Text } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useConfig from 'components/hooks/useConfig';
|
||||
import styles from './ProfileButton.module.css';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
|
||||
export function ProfileButton() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
const { cloudMode } = useConfig();
|
||||
const router = useRouter();
|
||||
const { dir } = useLocale();
|
||||
|
||||
const handleSelect = key => {
|
||||
if (key === 'profile') {
|
||||
router.push('/settings/profile');
|
||||
}
|
||||
if (key === 'logout') {
|
||||
router.push('/logout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<Icons.Profile />
|
||||
</Icon>
|
||||
<Icon size="sm">
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
|
||||
<Menu variant="popup" onSelect={handleSelect} className={styles.menu}>
|
||||
<Item key="user" className={styles.item}>
|
||||
<Text>{user.username}</Text>
|
||||
</Item>
|
||||
<Item key="profile" className={styles.item} divider={true}>
|
||||
<Icon>
|
||||
<Icons.User />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.profile)}</Text>
|
||||
</Item>
|
||||
{!cloudMode && (
|
||||
<Item key="logout" className={styles.item}>
|
||||
<Icon>
|
||||
<Icons.Logout />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.logout)}</Text>
|
||||
</Item>
|
||||
)}
|
||||
</Menu>
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileButton;
|
||||
10
src/components/input/ProfileButton.module.css
Normal file
10
src/components/input/ProfileButton.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.menu {
|
||||
width: 200px;
|
||||
z-index: var(--z-index-popup);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: var(--base50);
|
||||
}
|
||||
28
src/components/input/RefreshButton.js
Normal file
28
src/components/input/RefreshButton.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { LoadingButton, Icon, TooltipPopup } from 'react-basics';
|
||||
import { setWebsiteDateRange } from 'store/websites';
|
||||
import useDateRange from 'components/hooks/useDateRange';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function RefreshButton({ websiteId, isLoading }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [dateRange] = useDateRange(websiteId);
|
||||
|
||||
function handleClick() {
|
||||
if (!isLoading && dateRange) {
|
||||
setWebsiteDateRange(websiteId, dateRange);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipPopup label={formatMessage(labels.refresh)}>
|
||||
<LoadingButton loading={isLoading} onClick={handleClick}>
|
||||
<Icon>
|
||||
<Icons.Refresh />
|
||||
</Icon>
|
||||
</LoadingButton>
|
||||
</TooltipPopup>
|
||||
);
|
||||
}
|
||||
|
||||
export default RefreshButton;
|
||||
37
src/components/input/SettingsButton.js
Normal file
37
src/components/input/SettingsButton.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Button, Icon, PopupTrigger, Popup, Form, FormRow } from 'react-basics';
|
||||
import TimezoneSetting from 'components/pages/settings/profile/TimezoneSetting';
|
||||
import DateRangeSetting from 'components/pages/settings/profile/DateRangeSetting';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './SettingsButton.module.css';
|
||||
|
||||
export function SettingsButton() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<Icons.Gear />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popup
|
||||
className={styles.popup}
|
||||
position="bottom"
|
||||
alignment="end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Form>
|
||||
<FormRow label={formatMessage(labels.timezone)}>
|
||||
<TimezoneSetting />
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.defaultDateRange)}>
|
||||
<DateRangeSetting />
|
||||
</FormRow>
|
||||
</Form>
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsButton;
|
||||
11
src/components/input/SettingsButton.module.css
Normal file
11
src/components/input/SettingsButton.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.popup {
|
||||
background: var(--base50);
|
||||
border: 1px solid var(--base500);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
38
src/components/input/ThemeButton.js
Normal file
38
src/components/input/ThemeButton.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useTransition, animated } from 'react-spring';
|
||||
import { Button, Icon } from 'react-basics';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './ThemeButton.module.css';
|
||||
|
||||
export function ThemeButton() {
|
||||
const { theme, saveTheme } = useTheme();
|
||||
|
||||
const transitions = useTransition(theme, {
|
||||
initial: { opacity: 1 },
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: `translateY(${theme === 'light' ? '20px' : '-20px'}) scale(0.5)`,
|
||||
},
|
||||
enter: { opacity: 1, transform: 'translateY(0px) scale(1.0)' },
|
||||
leave: {
|
||||
opacity: 0,
|
||||
transform: `translateY(${theme === 'light' ? '-20px' : '20px'}) scale(0.5)`,
|
||||
},
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
saveTheme(theme === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="quiet" className={styles.button} onClick={handleClick}>
|
||||
{transitions((style, item) => (
|
||||
<animated.div key={item} style={style}>
|
||||
<Icon className={styles.icon}>{item === 'light' ? <Icons.Sun /> : <Icons.Moon />}</Icon>
|
||||
</animated.div>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeButton;
|
||||
14
src/components/input/ThemeButton.module.css
Normal file
14
src/components/input/ThemeButton.module.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.button {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button > div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
}
|
||||
25
src/components/input/WebsiteDateFilter.js
Normal file
25
src/components/input/WebsiteDateFilter.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import useDateRange from 'components/hooks/useDateRange';
|
||||
import DateFilter from './DateFilter';
|
||||
import styles from './WebsiteDateFilter.module.css';
|
||||
|
||||
export function WebsiteDateFilter({ websiteId }) {
|
||||
const [dateRange, setDateRange] = useDateRange(websiteId);
|
||||
const { value, startDate, endDate } = dateRange;
|
||||
|
||||
const handleChange = async value => {
|
||||
setDateRange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateFilter
|
||||
className={styles.dropdown}
|
||||
value={value}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={handleChange}
|
||||
showAllTime={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteDateFilter;
|
||||
3
src/components/input/WebsiteDateFilter.module.css
Normal file
3
src/components/input/WebsiteDateFilter.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.dropdown {
|
||||
min-width: 200px;
|
||||
}
|
||||
28
src/components/input/WebsiteSelect.js
Normal file
28
src/components/input/WebsiteSelect.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Dropdown, Item } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function WebsiteSelect({ websiteId, onSelect }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||
|
||||
const renderValue = value => {
|
||||
return data?.data?.find(({ id }) => id === value)?.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={data?.data}
|
||||
value={websiteId}
|
||||
renderValue={renderValue}
|
||||
onChange={onSelect}
|
||||
alignment="end"
|
||||
placeholder={formatMessage(labels.selectWebsite)}
|
||||
>
|
||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteSelect;
|
||||
32
src/components/layout/AppLayout.js
Normal file
32
src/components/layout/AppLayout.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Container } from 'react-basics';
|
||||
import Head from 'next/head';
|
||||
import NavBar from 'components/layout/NavBar';
|
||||
import UpdateNotice from 'components/common/UpdateNotice';
|
||||
import { useRequireLogin, useConfig } from 'components/hooks';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout({ title, children }) {
|
||||
const { user } = useRequireLogin();
|
||||
const config = useConfig();
|
||||
|
||||
if (!user || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<UpdateNotice user={user} config={config} />
|
||||
<Head>
|
||||
<title>{title ? `${title} | umami` : 'umami'}</title>
|
||||
</Head>
|
||||
<nav className={styles.nav}>
|
||||
<NavBar />
|
||||
</nav>
|
||||
<main className={styles.body}>
|
||||
<Container>{children}</Container>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
23
src/components/layout/AppLayout.module.css
Normal file
23
src/components/layout/AppLayout.module.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.layout {
|
||||
display: grid;
|
||||
grid-template-rows: max-content 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
grid-column: 1;
|
||||
grid-row: 2 / 3;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
14
src/components/layout/Footer.js
Normal file
14
src/components/layout/Footer.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { CURRENT_VERSION, HOMEPAGE_URL } from 'lib/constants';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<a href={HOMEPAGE_URL}>
|
||||
<b>umami</b> {`v${CURRENT_VERSION}`}
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
12
src/components/layout/Footer.module.css
Normal file
12
src/components/layout/Footer.module.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 30px;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--font-color100);
|
||||
}
|
||||
13
src/components/layout/Grid.js
Normal file
13
src/components/layout/Grid.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Row, Column } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Grid.module.css';
|
||||
|
||||
export function GridRow(props) {
|
||||
const { className, ...otherProps } = props;
|
||||
return <Row {...otherProps} className={classNames(styles.row, className)} />;
|
||||
}
|
||||
|
||||
export function GridColumn(props) {
|
||||
const { className, ...otherProps } = props;
|
||||
return <Column {...otherProps} className={classNames(styles.col, className)} />;
|
||||
}
|
||||
36
src/components/layout/Grid.module.css
Normal file
36
src/components/layout/Grid.module.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
border-top: 1px solid var(--base300);
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
.row > .col {
|
||||
border-inline-start: 1px solid var(--base300);
|
||||
}
|
||||
|
||||
.row > .col:first-child {
|
||||
border-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.row > .col:last-child {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.row {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.row > .col {
|
||||
border-top: 1px solid var(--base300);
|
||||
border-inline-start: 0;
|
||||
border-inline-end: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
31
src/components/layout/Header.js
Normal file
31
src/components/layout/Header.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Column, Icon, Row, Text } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import LanguageButton from 'components/input/LanguageButton';
|
||||
import ThemeButton from 'components/input/ThemeButton';
|
||||
import SettingsButton from 'components/input/SettingsButton';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './Header.module.css';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Row className={styles.row}>
|
||||
<Column>
|
||||
<Link href="https://umami.is" target="_blank" className={styles.title}>
|
||||
<Icon size="lg">
|
||||
<Icons.Logo />
|
||||
</Icon>
|
||||
<Text>umami</Text>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column className={styles.buttons}>
|
||||
<ThemeButton tooltipPosition="bottom" />
|
||||
<LanguageButton tooltipPosition="bottom" menuPosition="bottom" />
|
||||
<SettingsButton />
|
||||
</Column>
|
||||
</Row>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
47
src/components/layout/Header.module.css
Normal file
47
src/components/layout/Header.module.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--font-color100) !important;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.header .buttons {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.links {
|
||||
order: 2;
|
||||
margin: 20px 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.buttons,
|
||||
.links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
65
src/components/layout/NavBar.js
Normal file
65
src/components/layout/NavBar.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Icon, Text, Row, Column } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import Icons from 'components/icons';
|
||||
import ThemeButton from 'components/input/ThemeButton';
|
||||
import LanguageButton from 'components/input/LanguageButton';
|
||||
import ProfileButton from 'components/input/ProfileButton';
|
||||
import styles from './NavBar.module.css';
|
||||
import useConfig from 'components/hooks/useConfig';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
import HamburgerButton from '../common/HamburgerButton';
|
||||
|
||||
export function NavBar() {
|
||||
const { pathname } = useRouter();
|
||||
const { cloudMode } = useConfig();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const links = [
|
||||
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
|
||||
{ label: formatMessage(labels.websites), url: '/websites' },
|
||||
{ label: formatMessage(labels.reports), url: '/reports' },
|
||||
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
|
||||
].filter(n => n);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.navbar)}>
|
||||
<Row>
|
||||
<Column className={styles.left}>
|
||||
<div className={styles.logo}>
|
||||
<Icon size="lg">
|
||||
<Icons.Logo />
|
||||
</Icon>
|
||||
<Text className={styles.text}>umami</Text>
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
{links.map(({ url, label }) => {
|
||||
return (
|
||||
<Link
|
||||
key={url}
|
||||
href={url}
|
||||
className={classNames({ [styles.selected]: pathname.startsWith(url) })}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Column>
|
||||
<Column className={styles.right}>
|
||||
<div className={styles.actions}>
|
||||
<ThemeButton />
|
||||
<LanguageButton />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
<div className={styles.mobile}>
|
||||
<HamburgerButton />
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
87
src/components/layout/NavBar.module.css
Normal file
87
src/components/layout/NavBar.module.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
.navbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
background: var(--base75);
|
||||
border-bottom: 1px solid var(--base300);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 30px;
|
||||
padding: 0 40px;
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
line-height: 60px;
|
||||
color: var(--font-color200);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.links span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.links .selected {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.links,
|
||||
.actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
58
src/components/layout/NavGroup.js
Normal file
58
src/components/layout/NavGroup.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, Text, TooltipPopup } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './NavGroup.module.css';
|
||||
|
||||
export function NavGroup({
|
||||
title,
|
||||
items,
|
||||
defaultExpanded = true,
|
||||
allowExpand = true,
|
||||
minimized = false,
|
||||
}) {
|
||||
const { pathname } = useRouter();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleExpand = () => setExpanded(state => !state);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.group, {
|
||||
[styles.expanded]: expanded,
|
||||
[styles.minimized]: minimized,
|
||||
})}
|
||||
>
|
||||
{title && (
|
||||
<div className={styles.header} onClick={allowExpand ? handleExpand : undefined}>
|
||||
<Text>{title}</Text>
|
||||
<Icon size="sm" rotate={expanded ? 0 : -90}>
|
||||
<Icons.ChevronDown />
|
||||
</Icon>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
{items.map(({ label, url, icon, divider }) => {
|
||||
return (
|
||||
<TooltipPopup key={label} label={label} position="right" disabled={!minimized}>
|
||||
<Link
|
||||
href={url}
|
||||
className={classNames(styles.item, {
|
||||
[styles.divider]: divider,
|
||||
[styles.selected]: pathname.startsWith(url),
|
||||
})}
|
||||
>
|
||||
<Icon>{icon}</Icon>
|
||||
<Text className={styles.text}>{label}</Text>
|
||||
</Link>
|
||||
</TooltipPopup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavGroup;
|
||||
85
src/components/layout/NavGroup.module.css
Normal file
85
src/components/layout/NavGroup.module.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--base600);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expanded .body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-right: 2px solid var(--base200);
|
||||
padding: 1rem 2rem;
|
||||
gap: var(--size500);
|
||||
font-weight: 600;
|
||||
width: 200px;
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
a.item {
|
||||
color: var(--base700);
|
||||
}
|
||||
|
||||
.item.selected {
|
||||
color: var(--base900);
|
||||
border-right-color: var(--primary400);
|
||||
background: var(--blue100);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
color: var(--base900);
|
||||
}
|
||||
|
||||
.item.disabled {
|
||||
color: var(--base500) !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimized .text,
|
||||
.minimized .header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.minimized .item {
|
||||
width: 60px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
border-top: 1px solid var(--base300);
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.minimized .divider:before {
|
||||
width: 60px;
|
||||
}
|
||||
20
src/components/layout/Page.js
Normal file
20
src/components/layout/Page.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import classNames from 'classnames';
|
||||
import { Banner, Loading } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './Page.module.css';
|
||||
|
||||
export function Page({ className, error, loading, children }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
if (error) {
|
||||
return <Banner variant="error">{formatMessage(messages.error)}</Banner>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading icon="spinner" size="xl" position="page" />;
|
||||
}
|
||||
|
||||
return <div className={classNames(styles.page, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
7
src/components/layout/Page.module.css
Normal file
7
src/components/layout/Page.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--base50);
|
||||
position: relative;
|
||||
}
|
||||
14
src/components/layout/PageHeader.js
Normal file
14
src/components/layout/PageHeader.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
export function PageHeader({ title, children, className }) {
|
||||
return (
|
||||
<div className={classNames(styles.header, className)}>
|
||||
{title && <div className={styles.title}>{title}</div>}
|
||||
<div className={styles.actions}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
||||
48
src/components/layout/PageHeader.module.css
Normal file
48
src/components/layout/PageHeader.module.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
align-self: stretch;
|
||||
flex-wrap: wrap;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: var(--base600);
|
||||
}
|
||||
|
||||
.header a:hover {
|
||||
color: var(--base900);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
gap: 20px;
|
||||
height: 60px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-basis: 100%;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
23
src/components/layout/ReportsLayout.js
Normal file
23
src/components/layout/ReportsLayout.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Column, Row } from 'react-basics';
|
||||
import styles from './ReportsLayout.module.css';
|
||||
|
||||
export function ReportsLayout({ children, filter, header }) {
|
||||
return (
|
||||
<>
|
||||
<Row>{header}</Row>
|
||||
<Row>
|
||||
{filter && (
|
||||
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
|
||||
<h2>Filters</h2>
|
||||
{filter}
|
||||
</Column>
|
||||
)}
|
||||
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
|
||||
{children}
|
||||
</Column>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsLayout;
|
||||
23
src/components/layout/ReportsLayout.module.css
Normal file
23
src/components/layout/ReportsLayout.module.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.filter {
|
||||
margin-top: 30px;
|
||||
min-width: 200px;
|
||||
max-width: 100vw;
|
||||
padding: 10px;
|
||||
background: var(--base50);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter h2 {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
38
src/components/layout/SettingsLayout.js
Normal file
38
src/components/layout/SettingsLayout.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Row, Column } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import SideNav from './SideNav';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useConfig from 'components/hooks/useConfig';
|
||||
import styles from './SettingsLayout.module.css';
|
||||
|
||||
export function SettingsLayout({ children }) {
|
||||
const { user } = useUser();
|
||||
const { pathname } = useRouter();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { cloudMode } = useConfig();
|
||||
|
||||
const items = [
|
||||
{ key: 'websites', label: formatMessage(labels.websites), url: '/settings/websites' },
|
||||
{ key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
|
||||
user.isAdmin && { key: 'users', label: formatMessage(labels.users), url: '/settings/users' },
|
||||
{ key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' },
|
||||
].filter(n => n);
|
||||
|
||||
const getKey = () => items.find(({ url }) => pathname === url)?.key;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{!cloudMode && (
|
||||
<Column className={styles.menu} defaultSize={12} md={4} lg={3} xl={2}>
|
||||
<SideNav items={items} shallow={true} selectedKey={getKey()} />
|
||||
</Column>
|
||||
)}
|
||||
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={10}>
|
||||
{children}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsLayout;
|
||||
16
src/components/layout/SettingsLayout.module.css
Normal file
16
src/components/layout/SettingsLayout.module.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 40px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
15
src/components/layout/ShareLayout.js
Normal file
15
src/components/layout/ShareLayout.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Container } from 'react-basics';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export function ShareLayout({ children }) {
|
||||
return (
|
||||
<Container>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareLayout;
|
||||
25
src/components/layout/SideNav.js
Normal file
25
src/components/layout/SideNav.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import classNames from 'classnames';
|
||||
import { Menu, Item } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import styles from './SideNav.module.css';
|
||||
|
||||
export function SideNav({ selectedKey, items, shallow, onSelect = () => {} }) {
|
||||
const { asPath } = useRouter();
|
||||
return (
|
||||
<Menu items={items} selectedKey={selectedKey} className={styles.menu} onSelect={onSelect}>
|
||||
{({ key, label, url }) => (
|
||||
<Item
|
||||
key={key}
|
||||
className={classNames(styles.item, { [styles.selected]: asPath.startsWith(url) })}
|
||||
>
|
||||
<Link href={url} shallow={shallow}>
|
||||
{label}
|
||||
</Link>
|
||||
</Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default SideNav;
|
||||
19
src/components/layout/SideNav.module.css
Normal file
19
src/components/layout/SideNav.module.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--font-color100);
|
||||
flex: 1;
|
||||
padding: var(--size300) var(--size600);
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 700;
|
||||
}
|
||||
296
src/components/messages.js
Normal file
296
src/components/messages.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
export const labels = defineMessages({
|
||||
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
|
||||
required: { id: 'label.required', defaultMessage: 'Required' },
|
||||
save: { id: 'label.save', defaultMessage: 'Save' },
|
||||
cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
|
||||
continue: { id: 'label.continue', defaultMessage: 'Continue' },
|
||||
delete: { id: 'label.delete', defaultMessage: 'Delete' },
|
||||
leave: { id: 'label.leave', defaultMessage: 'Leave' },
|
||||
users: { id: 'label.users', defaultMessage: 'Users' },
|
||||
createUser: { id: 'label.create-user', defaultMessage: 'Create user' },
|
||||
deleteUser: { id: 'label.delete-user', defaultMessage: 'Delete user' },
|
||||
username: { id: 'label.username', defaultMessage: 'Username' },
|
||||
password: { id: 'label.password', defaultMessage: 'Password' },
|
||||
role: { id: 'label.role', defaultMessage: 'Role' },
|
||||
user: { id: 'label.user', defaultMessage: 'User' },
|
||||
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
|
||||
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
|
||||
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
||||
details: { id: 'label.details', defaultMessage: 'Details' },
|
||||
website: { id: 'label.website', defaultMessage: 'Website' },
|
||||
websites: { id: 'label.websites', defaultMessage: 'Websites' },
|
||||
myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' },
|
||||
teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' },
|
||||
created: { id: 'label.created', defaultMessage: 'Created' },
|
||||
edit: { id: 'label.edit', defaultMessage: 'Edit' },
|
||||
name: { id: 'label.name', defaultMessage: 'Name' },
|
||||
members: { id: 'label.members', defaultMessage: 'Members' },
|
||||
accessCode: { id: 'label.access-code', defaultMessage: 'Access code' },
|
||||
teamId: { id: 'label.team-id', defaultMessage: 'Team ID' },
|
||||
team: { id: 'label.team', defaultMessage: 'Team' },
|
||||
teamName: { id: 'label.team-name', defaultMessage: 'Team name' },
|
||||
regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' },
|
||||
remove: { id: 'label.remove', defaultMessage: 'Remove' },
|
||||
join: { id: 'label.join', defaultMessage: 'Join' },
|
||||
createTeam: { id: 'label.create-team', defaultMessage: 'Create team' },
|
||||
joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
|
||||
settings: { id: 'label.settings', defaultMessage: 'Settings' },
|
||||
owner: { id: 'label.owner', defaultMessage: 'Owner' },
|
||||
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
|
||||
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
|
||||
teamGuest: { id: 'label.team-guest', defaultMessage: 'Team guest' },
|
||||
enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' },
|
||||
data: { id: 'label.data', defaultMessage: 'Data' },
|
||||
trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
|
||||
shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' },
|
||||
actions: { id: 'label.actions', defaultMessage: 'Actions' },
|
||||
domain: { id: 'label.domain', defaultMessage: 'Domain' },
|
||||
websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
|
||||
resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' },
|
||||
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
|
||||
reset: { id: 'label.reset', defaultMessage: 'Reset' },
|
||||
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
|
||||
addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
|
||||
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
|
||||
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
|
||||
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
|
||||
confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' },
|
||||
timezone: { id: 'label.timezone', defaultMessage: 'Timezone' },
|
||||
defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' },
|
||||
language: { id: 'label.language', defaultMessage: 'Language' },
|
||||
theme: { id: 'label.theme', defaultMessage: 'Theme' },
|
||||
profile: { id: 'label.profile', defaultMessage: 'Profile' },
|
||||
dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' },
|
||||
more: { id: 'label.more', defaultMessage: 'More' },
|
||||
realtime: { id: 'label.realtime', defaultMessage: 'Realtime' },
|
||||
queries: { id: 'label.queries', defaultMessage: 'Queries' },
|
||||
teams: { id: 'label.teams', defaultMessage: 'Teams' },
|
||||
analytics: { id: 'label.analytics', defaultMessage: 'Analytics' },
|
||||
login: { id: 'label.login', defaultMessage: 'Login' },
|
||||
logout: { id: 'label.logout', defaultMessage: 'Logout' },
|
||||
singleDay: { id: 'label.single-day', defaultMessage: 'Single day' },
|
||||
dateRange: { id: 'label.date-range', defaultMessage: 'Date range' },
|
||||
viewDetails: { id: 'label.view-details', defaultMessage: 'View details' },
|
||||
deleteTeam: { id: 'label.delete-team', defaultMessage: 'Delete team' },
|
||||
leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' },
|
||||
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
|
||||
pages: { id: 'label.pages', defaultMessage: 'Pages' },
|
||||
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
|
||||
screens: { id: 'label.screens', defaultMessage: 'Screens' },
|
||||
browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
|
||||
os: { id: 'label.os', defaultMessage: 'OS' },
|
||||
devices: { id: 'label.devices', defaultMessage: 'Devices' },
|
||||
countries: { id: 'label.countries', defaultMessage: 'Countries' },
|
||||
languages: { id: 'label.languages', defaultMessage: 'Languages' },
|
||||
event: { id: 'label.event', defaultMessage: 'Event' },
|
||||
events: { id: 'label.events', defaultMessage: 'Events' },
|
||||
query: { id: 'label.query', defaultMessage: 'Query' },
|
||||
queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
|
||||
back: { id: 'label.back', defaultMessage: 'Back' },
|
||||
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
|
||||
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
|
||||
filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
|
||||
views: { id: 'label.views', defaultMessage: 'Views' },
|
||||
none: { id: 'label.none', defaultMessage: 'None' },
|
||||
clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
|
||||
today: { id: 'label.today', defaultMessage: 'Today' },
|
||||
lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
|
||||
yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
|
||||
thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
|
||||
lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
|
||||
thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
|
||||
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
|
||||
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
|
||||
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
|
||||
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
||||
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
|
||||
all: { id: 'label.all', defaultMessage: 'All' },
|
||||
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
||||
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
|
||||
activityLog: { id: 'label.activity-log', defaultMessage: 'Activity log' },
|
||||
dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' },
|
||||
poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
|
||||
pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
|
||||
uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
|
||||
bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
|
||||
averageVisitTime: { id: 'label.average-visit-time', defaultMessage: 'Average visit time' },
|
||||
desktop: { id: 'label.desktop', defaultMessage: 'Desktop' },
|
||||
laptop: { id: 'label.laptop', defaultMessage: 'Laptop' },
|
||||
tablet: { id: 'label.tablet', defaultMessage: 'Tablet' },
|
||||
mobile: { id: 'label.mobile', defaultMessage: 'Mobile' },
|
||||
toggleCharts: { id: 'label.toggle-charts', defaultMessage: 'Toggle charts' },
|
||||
editDashboard: { id: 'label.edit-dashboard', defaultMessage: 'Edit dashboard' },
|
||||
title: { id: 'label.title', defaultMessage: 'Title' },
|
||||
view: { id: 'label.view', defaultMessage: 'View' },
|
||||
cities: { id: 'label.cities', defaultMessage: 'Cities' },
|
||||
regions: { id: 'label.regions', defaultMessage: 'Regions' },
|
||||
reports: { id: 'label.reports', defaultMessage: 'Reports' },
|
||||
eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
|
||||
funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
|
||||
url: { id: 'label.url', defaultMessage: 'URL' },
|
||||
urls: { id: 'label.urls', defaultMessage: 'URLs' },
|
||||
add: { id: 'label.add', defaultMessage: 'Add' },
|
||||
window: { id: 'label.window', defaultMessage: 'Window' },
|
||||
runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
|
||||
field: { id: 'label.field', defaultMessage: 'Field' },
|
||||
fields: { id: 'label.fields', defaultMessage: 'Fields' },
|
||||
createReport: { id: 'label.create-report', defaultMessage: 'Create report' },
|
||||
description: { id: 'label.description', defaultMessage: 'Description' },
|
||||
untitled: { id: 'label.untitled', defaultMessage: 'Untitled' },
|
||||
type: { id: 'label.type', defaultMessage: 'Type' },
|
||||
filters: { id: 'label.filters', defaultMessage: 'Filters' },
|
||||
breakdown: { id: 'label.breakdown', defaultMessage: 'Breakdown' },
|
||||
true: { id: 'label.true', defaultMessage: 'True' },
|
||||
false: { id: 'label.false', defaultMessage: 'False' },
|
||||
is: { id: 'label.is', defaultMessage: 'Is' },
|
||||
isNot: { id: 'label.is-not', defaultMessage: 'Is not' },
|
||||
isSet: { id: 'label.is-set', defaultMessage: 'Is set' },
|
||||
isNotSet: { id: 'label.is-not-set', defaultMessage: 'Is not set' },
|
||||
greaterThan: { id: 'label.greater-than', defaultMessage: 'Greater than' },
|
||||
lessThan: { id: 'label.less-than', defaultMessage: 'Less than' },
|
||||
greaterThanEquals: { id: 'label.greater-than-equals', defaultMessage: 'Greater than or equals' },
|
||||
lessThanEquals: { id: 'label.less-than-equals', defaultMessage: 'Less than or equals' },
|
||||
contains: { id: 'label.contains', defaultMessage: 'Contains' },
|
||||
doesNotContain: { id: 'label.does-not-contain', defaultMessage: 'Does not contain' },
|
||||
before: { id: 'label.before', defaultMessage: 'Before' },
|
||||
after: { id: 'label.after', defaultMessage: 'After' },
|
||||
total: { id: 'label.total', defaultMessage: 'Total' },
|
||||
sum: { id: 'label.sum', defaultMessage: 'Sum' },
|
||||
average: { id: 'label.average', defaultMessage: 'Average' },
|
||||
min: { id: 'label.min', defaultMessage: 'Min' },
|
||||
max: { id: 'label.max', defaultMessage: 'Max' },
|
||||
unique: { id: 'label.unique', defaultMessage: 'Unique' },
|
||||
value: { id: 'label.value', defaultMessage: 'Value' },
|
||||
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
||||
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
||||
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
||||
retention: { id: 'label.retention', defaultMessage: 'Retention' },
|
||||
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
|
||||
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
|
||||
country: { id: 'label.country', defaultMessage: 'Country' },
|
||||
region: { id: 'label.region', defaultMessage: 'Region' },
|
||||
city: { id: 'label.city', defaultMessage: 'City' },
|
||||
browser: { id: 'label.browser', defaultMessage: 'Browser' },
|
||||
device: { id: 'label.device', defaultMessage: 'Device' },
|
||||
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
|
||||
day: { id: 'label.day', defaultMessage: 'Day' },
|
||||
date: { id: 'label.date', defaultMessage: 'Date' },
|
||||
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
|
||||
saved: { id: 'message.saved', defaultMessage: 'Saved.' },
|
||||
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
||||
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
||||
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
||||
confirmReset: {
|
||||
id: 'message.confirm-reset',
|
||||
defaultMessage: 'Are you sure you want to reset {target}?',
|
||||
},
|
||||
confirmDelete: {
|
||||
id: 'message.confirm-delete',
|
||||
defaultMessage: 'Are you sure you want to delete {target}?',
|
||||
},
|
||||
confirmLeave: {
|
||||
id: 'message.confirm-leave',
|
||||
defaultMessage: 'Are you sure you want to leave {target}?',
|
||||
},
|
||||
minPasswordLength: {
|
||||
id: 'message.min-password-length',
|
||||
defaultMessage: 'Minimum length of {n} characters',
|
||||
},
|
||||
noTeams: {
|
||||
id: 'message.no-teams',
|
||||
defaultMessage: 'You have not created any teams.',
|
||||
},
|
||||
shareUrl: {
|
||||
id: 'message.share-url',
|
||||
defaultMessage: 'Your website stats are publically available at the following URL:',
|
||||
},
|
||||
trackingCode: {
|
||||
id: 'message.tracking-code',
|
||||
defaultMessage:
|
||||
'To track stats for this website, place the following code in the <head>...</head> section of your HTML.',
|
||||
},
|
||||
joinTeamWarning: {
|
||||
id: 'message.team-already-member',
|
||||
defaultMessage: 'You are already a member of the team.',
|
||||
},
|
||||
deleteAccount: {
|
||||
id: 'message.delete-account',
|
||||
defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.',
|
||||
},
|
||||
deleteWebsite: {
|
||||
id: 'message.delete-website',
|
||||
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
|
||||
},
|
||||
resetWebsite: {
|
||||
id: 'message.reset-website',
|
||||
defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
|
||||
},
|
||||
invalidDomain: {
|
||||
id: 'message.invalid-domain',
|
||||
defaultMessage: 'Invalid domain. Do not include http/https.',
|
||||
},
|
||||
resetWebsiteWarning: {
|
||||
id: 'message.reset-website-warning',
|
||||
defaultMessage:
|
||||
'All statistics for this website will be deleted, but your settings will remain intact.',
|
||||
},
|
||||
deleteWebsiteWarning: {
|
||||
id: 'message.delete-website-warning',
|
||||
defaultMessage: 'All website data will be deleted.',
|
||||
},
|
||||
noResultsFound: {
|
||||
id: 'message.no-results-found',
|
||||
defaultMessage: 'No results found.',
|
||||
},
|
||||
noWebsitesConfigured: {
|
||||
id: 'message.no-websites-configured',
|
||||
defaultMessage: 'You do not have any websites configured.',
|
||||
},
|
||||
noTeamWebsites: {
|
||||
id: 'message.no-team-websites',
|
||||
defaultMessage: 'This team does not have any websites.',
|
||||
},
|
||||
teamWebsitesInfo: {
|
||||
id: 'message.team-websites-info',
|
||||
defaultMessage: 'Websites can be viewed by anyone on the team.',
|
||||
},
|
||||
noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
|
||||
goToSettings: {
|
||||
id: 'message.go-to-settings',
|
||||
defaultMessage: 'Go to settings',
|
||||
},
|
||||
activeUsers: {
|
||||
id: 'message.active-users',
|
||||
defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}',
|
||||
},
|
||||
teamNotFound: {
|
||||
id: 'message.team-not-found',
|
||||
defaultMessage: 'Team not found.',
|
||||
},
|
||||
visitorLog: {
|
||||
id: 'message.visitor-log',
|
||||
defaultMessage: 'Visitor from {country} using {browser} on {os} {device}',
|
||||
},
|
||||
eventLog: {
|
||||
id: 'message.event-log',
|
||||
defaultMessage: '{event} on {url}',
|
||||
},
|
||||
incorrectUsernamePassword: {
|
||||
id: 'message.incorrect-username-password',
|
||||
defaultMessage: 'Incorrect username and/or password.',
|
||||
},
|
||||
noEventData: {
|
||||
id: 'message.no-event-data',
|
||||
defaultMessage: 'No event data is available.',
|
||||
},
|
||||
newVersionAvailable: {
|
||||
id: 'message.new-version-available',
|
||||
defaultMessage: 'A new version of Umami {version} is available!',
|
||||
},
|
||||
});
|
||||
38
src/components/metrics/ActiveUsers.js
Normal file
38
src/components/metrics/ActiveUsers.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react';
|
||||
import { StatusLight } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './ActiveUsers.module.css';
|
||||
|
||||
export function ActiveUsers({ websiteId, value, refetchInterval = 60000 }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data } = useQuery(
|
||||
['websites:active', websiteId],
|
||||
() => get(`/websites/${websiteId}/active`),
|
||||
{
|
||||
refetchInterval,
|
||||
enabled: !!websiteId,
|
||||
},
|
||||
);
|
||||
|
||||
const count = useMemo(() => {
|
||||
if (websiteId) {
|
||||
return data?.[0]?.x || 0;
|
||||
}
|
||||
|
||||
return value !== undefined ? value : 0;
|
||||
}, [data, value, websiteId]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusLight className={styles.container} variant="success">
|
||||
<div className={styles.text}>{formatMessage(messages.activeUsers, { x: count })}</div>
|
||||
</StatusLight>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActiveUsers;
|
||||
17
src/components/metrics/ActiveUsers.module.css
Normal file
17
src/components/metrics/ActiveUsers.module.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
163
src/components/metrics/BarChart.js
Normal file
163
src/components/metrics/BarChart.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Loading } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import Chart from 'chart.js/auto';
|
||||
import HoverTooltip from 'components/common/HoverTooltip';
|
||||
import Legend from 'components/metrics/Legend';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
import { renderNumberLabels } from 'lib/charts';
|
||||
import styles from './BarChart.module.css';
|
||||
|
||||
export function BarChart({
|
||||
datasets,
|
||||
unit,
|
||||
animationDuration = DEFAULT_ANIMATION_DURATION,
|
||||
stacked = false,
|
||||
loading = false,
|
||||
renderXLabel,
|
||||
renderYLabel,
|
||||
XAxisType = 'time',
|
||||
YAxisType = 'linear',
|
||||
renderTooltipPopup,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
className,
|
||||
}) {
|
||||
const canvas = useRef();
|
||||
const chart = useRef(null);
|
||||
const [tooltip, setTooltipPopup] = useState(null);
|
||||
const { locale } = useLocale();
|
||||
const { theme, colors } = useTheme();
|
||||
|
||||
const getOptions = useCallback(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: animationDuration,
|
||||
resize: {
|
||||
duration: 0,
|
||||
},
|
||||
active: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: XAxisType,
|
||||
stacked: true,
|
||||
time: {
|
||||
unit,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.chart.text,
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
callback: renderXLabel,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: YAxisType,
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
stacked,
|
||||
grid: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
border: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
callback: renderYLabel || renderNumberLabels,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [
|
||||
animationDuration,
|
||||
renderTooltipPopup,
|
||||
renderXLabel,
|
||||
XAxisType,
|
||||
YAxisType,
|
||||
stacked,
|
||||
colors,
|
||||
unit,
|
||||
locale,
|
||||
]);
|
||||
|
||||
const createChart = () => {
|
||||
Chart.defaults.font.family = 'Inter';
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
chart.current = new Chart(canvas.current, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets,
|
||||
},
|
||||
options,
|
||||
});
|
||||
|
||||
onCreate?.(chart.current);
|
||||
};
|
||||
|
||||
const updateChart = () => {
|
||||
setTooltipPopup(null);
|
||||
|
||||
datasets.forEach((dataset, index) => {
|
||||
chart.current.data.datasets[index].data = dataset.data;
|
||||
chart.current.data.datasets[index].label = dataset.label;
|
||||
});
|
||||
|
||||
chart.current.options = getOptions();
|
||||
|
||||
onUpdate?.(chart.current);
|
||||
|
||||
chart.current.update();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (datasets) {
|
||||
if (!chart.current) {
|
||||
createChart();
|
||||
} else {
|
||||
updateChart();
|
||||
}
|
||||
}
|
||||
}, [datasets, unit, theme, animationDuration, locale]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames(styles.chart, className)}>
|
||||
{loading && <Loading position="page" icon="dots" />}
|
||||
<canvas ref={canvas} />
|
||||
</div>
|
||||
<Legend chart={chart.current} />
|
||||
{tooltip && (
|
||||
<HoverTooltip>
|
||||
<div className={styles.tooltip}>{tooltip}</div>
|
||||
</HoverTooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarChart;
|
||||
15
src/components/metrics/BarChart.module.css
Normal file
15
src/components/metrics/BarChart.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.chart {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tooltip .value {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue