Moved code into src folder. Added build for component library.

This commit is contained in:
Mike Cao 2023-08-21 02:06:09 -07:00
parent 7a7233ead4
commit ede658771e
490 changed files with 749 additions and 442 deletions

View 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;

View 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;

View 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%;
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View file

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

View 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;

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,9 @@
.button {
display: none;
}
@media only screen and (max-width: 768px) {
.button {
display: flex;
}
}

View 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;

View file

@ -0,0 +1,6 @@
.tooltip {
position: fixed;
pointer-events: none;
z-index: var(--z-index-popup);
transform: translate(-50%, calc(-100% - 5px));
}

View 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;

View 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);
}

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,7 @@
.container {
margin-top: 20px;
}
.text {
margin: 0 16px;
}

View 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;

View 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%;
}
}

View 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;

View 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;
}
}

View 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;

View file

@ -0,0 +1,4 @@
.container {
overflow: hidden;
position: relative;
}

2
src/components/declarations.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare module '*.css';
declare module '*.svg';

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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);
}

View 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;

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,10 @@
.menu {
width: 200px;
z-index: var(--z-index-popup);
}
.item {
display: flex;
gap: 12px;
background: var(--base50);
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,3 @@
.dropdown {
min-width: 200px;
}

View 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;

View 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;

View 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;
}

View 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;

View 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);
}

View 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)} />;
}

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,7 @@
.page {
flex: 1;
display: flex;
flex-direction: column;
background: var(--base50);
position: relative;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;

View 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
View 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!',
},
});

View 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;

View 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;
}

View 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;

View 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