mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Responsive everything.
This commit is contained in:
parent
9df012084d
commit
4d2a7ea947
23 changed files with 286 additions and 287 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@umami/components",
|
||||
"version": "0.129.0",
|
||||
"version": "0.130.0",
|
||||
"description": "Umami React components.",
|
||||
"author": "Mike Cao <mike@mikecao.com>",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
"@react-spring/web": "^10.0.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@umami/react-zen": "^0.196.0",
|
||||
"@umami/react-zen": "^0.198.0",
|
||||
"@umami/redis-client": "^0.29.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chalk": "^5.6.2",
|
||||
|
|
|
|||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -45,8 +45,8 @@ importers:
|
|||
specifier: ^5.90.2
|
||||
version: 5.90.2(react@19.1.1)
|
||||
'@umami/react-zen':
|
||||
specifier: ^0.196.0
|
||||
version: 0.196.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))
|
||||
specifier: ^0.198.0
|
||||
version: 0.198.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))
|
||||
'@umami/redis-client':
|
||||
specifier: ^0.29.0
|
||||
version: 0.29.0
|
||||
|
|
@ -364,6 +364,8 @@ importers:
|
|||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
dist: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
|
|
@ -2754,8 +2756,8 @@ packages:
|
|||
resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@umami/react-zen@0.196.0':
|
||||
resolution: {integrity: sha512-CLxrDAJOdo+0aJAclOq7naIDg+2I5wP9wXxAFhxhQVPXHV8yUHqH9Ula632cLMo51JYp0l+eEtOtuimpuKX3jg==}
|
||||
'@umami/react-zen@0.198.0':
|
||||
resolution: {integrity: sha512-usxXJPz5XnHzJ3Qo2RD0UZw+De/1QOx1f0SJDZNx8TZGLBNFqUHTAZgwOZANP7JgkS9cd4Q+WSobqP4JOp2saQ==}
|
||||
|
||||
'@umami/redis-client@0.29.0':
|
||||
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
|
||||
|
|
@ -10385,7 +10387,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.45.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@umami/react-zen@0.196.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))':
|
||||
'@umami/react-zen@0.198.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))':
|
||||
dependencies:
|
||||
'@fontsource/jetbrains-mono': 5.2.8
|
||||
'@internationalized/date': 3.10.0
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import {
|
||||
Row,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
Icon,
|
||||
Modal,
|
||||
NavMenu,
|
||||
NavMenuItem,
|
||||
IconLabel,
|
||||
Text,
|
||||
Grid,
|
||||
} from '@umami/react-zen';
|
||||
import { Globe, Grid2x2, LinkIcon, Menu } from '@/components/icons';
|
||||
import { Row, NavMenu, NavMenuItem, IconLabel, Text, Grid } from '@umami/react-zen';
|
||||
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import Link from 'next/link';
|
||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
||||
import { Logo } from '@/components/svg';
|
||||
import { NavButton } from '@/components/input/NavButton';
|
||||
import { MobileMenu } from '@/components/common/MobileMenu';
|
||||
|
||||
export function MobileNav() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -45,30 +34,27 @@ export function MobileNav() {
|
|||
|
||||
return (
|
||||
<Grid columns="auto 1fr" flexGrow={1}>
|
||||
<DialogTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Menu />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Modal position="left" offset="80px">
|
||||
<Dialog variant="sheet">
|
||||
<NavMenu padding="3">
|
||||
<NavButton />
|
||||
{links.map(link => {
|
||||
return (
|
||||
<Link key={link.id} href={link.path}>
|
||||
<NavMenuItem>
|
||||
<IconLabel icon={link.icon} label={link.label} />
|
||||
</NavMenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</NavMenu>
|
||||
{websiteId && <WebsiteNav websiteId={websiteId} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
<MobileMenu>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<>
|
||||
<NavMenu padding="3" onItemClick={close} border="bottom">
|
||||
<NavButton />
|
||||
{links.map(link => {
|
||||
return (
|
||||
<Link key={link.id} href={link.path}>
|
||||
<NavMenuItem>
|
||||
<IconLabel icon={link.icon} label={link.label} />
|
||||
</NavMenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</NavMenu>
|
||||
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</MobileMenu>
|
||||
<Row alignItems="center" justifyContent="center" flexGrow={1}>
|
||||
<IconLabel icon={<Logo />} style={{ width: 'auto' }}>
|
||||
<Text weight="bold">umami</Text>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Dialog, Modal } from '@umami/react-zen';
|
||||
import { Dialog, Modal, useBreakpoint } from '@umami/react-zen';
|
||||
import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
|
||||
import { useNavigation } from '@/components/hooks';
|
||||
|
||||
|
|
@ -14,6 +14,8 @@ export function ExpandedViewModal({
|
|||
query: { view },
|
||||
updateParams,
|
||||
} = useNavigation();
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
|
||||
|
||||
const handleClose = (close: () => void) => {
|
||||
router.push(updateParams({ view: undefined }));
|
||||
|
|
@ -28,7 +30,13 @@ export function ExpandedViewModal({
|
|||
|
||||
return (
|
||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
||||
<Dialog
|
||||
style={{
|
||||
maxWidth: 1320,
|
||||
width: '100vw',
|
||||
height: isMobile ? '100dvh' : 'calc(100dvh - 40px)',
|
||||
}}
|
||||
>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<WebsiteExpandedView
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ import {
|
|||
} from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
|
||||
export function WebsiteExpandedMenu({ excludedIds = [] }: { excludedIds?: string[] }) {
|
||||
export function WebsiteExpandedMenu({
|
||||
excludedIds = [],
|
||||
onItemClick,
|
||||
}: {
|
||||
excludedIds?: string[];
|
||||
onItemClick?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
updateParams,
|
||||
|
|
@ -173,5 +179,5 @@ export function WebsiteExpandedMenu({ excludedIds = [] }: { excludedIds?: string
|
|||
},
|
||||
];
|
||||
|
||||
return <SideMenu items={items} selectedKey={view} />;
|
||||
return <SideMenu items={items} selectedKey={view} onItemClick={onItemClick} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Grid, Column } from '@umami/react-zen';
|
||||
import { Grid, Column, Row } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
|
||||
import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu';
|
||||
import { MobileMenu } from '@/components/common/MobileMenu';
|
||||
|
||||
export function WebsiteExpandedView({
|
||||
websiteId,
|
||||
|
|
@ -18,24 +19,33 @@ export function WebsiteExpandedView({
|
|||
} = useNavigation();
|
||||
|
||||
return (
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden">
|
||||
<Column
|
||||
display={{ xs: 'none', md: 'flex' }}
|
||||
gap="6"
|
||||
border="right"
|
||||
paddingRight="3"
|
||||
overflowY="auto"
|
||||
>
|
||||
<WebsiteExpandedMenu excludedIds={excludedIds} />
|
||||
</Column>
|
||||
<Column overflow="hidden">
|
||||
<MetricsExpandedTable
|
||||
title={formatMessage(labels[view])}
|
||||
type={view}
|
||||
websiteId={websiteId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Column gap>
|
||||
<Row display={{ xs: 'flex', md: 'none' }}>
|
||||
<MobileMenu>
|
||||
{({ close }) => {
|
||||
return <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />;
|
||||
}}
|
||||
</MobileMenu>
|
||||
</Row>
|
||||
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden">
|
||||
<Column
|
||||
display={{ xs: 'none', md: 'flex' }}
|
||||
gap="6"
|
||||
border="right"
|
||||
paddingRight="3"
|
||||
overflow="auto"
|
||||
>
|
||||
<WebsiteExpandedMenu excludedIds={excludedIds} />
|
||||
</Column>
|
||||
<Column overflow="hidden">
|
||||
<MetricsExpandedTable
|
||||
title={formatMessage(labels[view])}
|
||||
type={view}
|
||||
websiteId={websiteId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Text } from '@umami/react-zen';
|
||||
import { Text, Column } from '@umami/react-zen';
|
||||
import {
|
||||
Eye,
|
||||
User,
|
||||
|
|
@ -14,7 +14,13 @@ import { useMessages, useNavigation } from '@/components/hooks';
|
|||
import { SideMenu } from '@/components/common/SideMenu';
|
||||
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
||||
|
||||
export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||
export function WebsiteNav({
|
||||
websiteId,
|
||||
onItemClick,
|
||||
}: {
|
||||
websiteId: string;
|
||||
onItemClick?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname, renderUrl, teamId, router } = useNavigation();
|
||||
|
||||
|
|
@ -155,7 +161,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
|
||||
|
||||
return (
|
||||
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}>
|
||||
<Column padding="3" gap>
|
||||
<WebsiteSelect
|
||||
websiteId={websiteId}
|
||||
teamId={teamId}
|
||||
|
|
@ -163,6 +169,12 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
renderValue={renderValue}
|
||||
buttonProps={{ style: { outline: 'none' } }}
|
||||
/>
|
||||
</SideMenu>
|
||||
<SideMenu
|
||||
items={items}
|
||||
selectedKey={selectedKey}
|
||||
allowMinimize={false}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Icon, TextField, Column, Row, Label } from '@umami/react-zen';
|
||||
import { Icon, Grid, Column, Row, Label } from '@umami/react-zen';
|
||||
import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import { KeyRound, Calendar } from '@/components/icons';
|
||||
import { Location } from '@/components/svg';
|
||||
import { KeyRound, Calendar, MapPin, Landmark } from '@/components/icons';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
|
||||
export function SessionInfo({ data }) {
|
||||
|
|
@ -13,11 +12,7 @@ export function SessionInfo({ data }) {
|
|||
const { getRegionName } = useRegionNames(locale);
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<Info label="ID">
|
||||
<TextField value={data?.id} style={{ width: '100%' }} allowCopy />
|
||||
</Info>
|
||||
|
||||
<Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap>
|
||||
<Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
|
||||
{data?.distinctId}
|
||||
</Info>
|
||||
|
|
@ -37,11 +32,11 @@ export function SessionInfo({ data }) {
|
|||
{formatValue(data?.country, 'country')}
|
||||
</Info>
|
||||
|
||||
<Info label={formatMessage(labels.region)} icon={<Location />}>
|
||||
<Info label={formatMessage(labels.region)} icon={<MapPin />}>
|
||||
{getRegionName(data?.region)}
|
||||
</Info>
|
||||
|
||||
<Info label={formatMessage(labels.city)} icon={<Location />}>
|
||||
<Info label={formatMessage(labels.city)} icon={<Landmark />}>
|
||||
{data?.city}
|
||||
</Info>
|
||||
|
||||
|
|
@ -65,7 +60,7 @@ export function SessionInfo({ data }) {
|
|||
>
|
||||
{formatValue(data?.device, 'device')}
|
||||
</Info>
|
||||
</Column>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { Grid, Row, Column, Tabs, TabList, Tab, TabPanel, Icon, Button } from '@umami/react-zen';
|
||||
import {
|
||||
TextField,
|
||||
Row,
|
||||
Column,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Icon,
|
||||
Button,
|
||||
} from '@umami/react-zen';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { X } from '@/components/icons';
|
||||
|
|
@ -37,35 +47,34 @@ export function SessionProfile({
|
|||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
<Grid columns="340px 1fr" gap="6">
|
||||
<Column gap="6">
|
||||
<Row justifyContent="center">
|
||||
<Avatar seed={data?.id} size={128} />
|
||||
</Row>
|
||||
<SessionInfo data={data} />
|
||||
</Column>
|
||||
<Column gap>
|
||||
<SessionStats data={data} />
|
||||
<Column gap="6">
|
||||
<Row justifyContent="center" alignItems="center" gap="6">
|
||||
<Avatar seed={data?.id} size={128} />
|
||||
<Column width="360px">
|
||||
<TextField label="ID" value={data?.id} allowCopy />
|
||||
</Column>
|
||||
</Row>
|
||||
<SessionStats data={data} />
|
||||
<SessionInfo data={data} />
|
||||
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="activity">
|
||||
<SessionActivity
|
||||
websiteId={websiteId}
|
||||
sessionId={sessionId}
|
||||
startDate={data?.firstAt}
|
||||
endDate={data?.lastAt}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="properties">
|
||||
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="activity">
|
||||
<SessionActivity
|
||||
websiteId={websiteId}
|
||||
sessionId={sessionId}
|
||||
startDate={data?.firstAt}
|
||||
endDate={data?.lastAt}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="properties">
|
||||
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
</Column>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
|
|
|
|||
|
|
@ -65,11 +65,11 @@ export function FilterRecord({
|
|||
<Column>
|
||||
<Label>{fields.find(f => f.name === name)?.label}</Label>
|
||||
<Grid columns="1fr auto" gap>
|
||||
<Grid columns="200px 1fr" gap>
|
||||
<Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap>
|
||||
<Select
|
||||
items={operators.filter(({ type }) => type === 'string')}
|
||||
value={operator}
|
||||
onSelectionChange={handleSelectOperator}
|
||||
onChange={handleSelectOperator}
|
||||
>
|
||||
{({ name, label }: any) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { Button, Icon } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { Close, Menu } from '@/components/icons';
|
||||
import { MobileMenu } from './MobileMenu';
|
||||
|
||||
export function HamburgerButton({ menuItems }: { menuItems: any[] }) {
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const handleClick = () => setActive(state => !state);
|
||||
const handleClose = () => setActive(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="quiet" onClick={handleClick}>
|
||||
<Icon>{active ? <Close /> : <Menu />}</Icon>
|
||||
</Button>
|
||||
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export function LoadingPanel({
|
|||
renderEmpty = () => <Empty />,
|
||||
children,
|
||||
...props
|
||||
}: LoadingPanelProps) {
|
||||
}: LoadingPanelProps): ReactNode {
|
||||
const empty = isEmpty ?? checkEmpty(data);
|
||||
|
||||
// Show loading spinner only if no data exists
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
.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-inline-start: 40px;
|
||||
}
|
||||
|
|
@ -1,45 +1,17 @@
|
|||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styles from './MobileMenu.module.css';
|
||||
import { Dialog, DialogTrigger, Button, Icon, Modal, DialogProps } from '@umami/react-zen';
|
||||
import { Menu } from '@/components/icons';
|
||||
|
||||
export function MobileMenu({
|
||||
items = [],
|
||||
onClose,
|
||||
}: {
|
||||
items: any[];
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}): any {
|
||||
const pathname = usePathname();
|
||||
|
||||
const Items = ({ items, className }: { items: any[]; className?: string }): any => (
|
||||
<div className={classNames(styles.items, className)}>
|
||||
{items.map(({ label, url, children }: { label: string; url: string; children: any[] }) => {
|
||||
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 createPortal(
|
||||
<div className={classNames(styles.menu)}>
|
||||
<Items items={items} />
|
||||
</div>,
|
||||
document.body,
|
||||
export function MobileMenu(props: DialogProps) {
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Menu />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Modal position="left" offset="80px">
|
||||
<Dialog variant="sheet" {...props} />
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Heading,
|
||||
NavMenu,
|
||||
|
|
@ -28,7 +27,6 @@ export interface SideMenuProps extends NavMenuProps {
|
|||
title?: string;
|
||||
selectedKey?: string;
|
||||
allowMinimize?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function SideMenu({
|
||||
|
|
@ -36,7 +34,6 @@ export function SideMenu({
|
|||
title,
|
||||
selectedKey,
|
||||
allowMinimize,
|
||||
children,
|
||||
...props
|
||||
}: SideMenuProps) {
|
||||
const renderItems = (items: SideMenuData[]) => {
|
||||
|
|
@ -54,16 +51,7 @@ export function SideMenu({
|
|||
};
|
||||
|
||||
return (
|
||||
<Column
|
||||
gap
|
||||
padding
|
||||
overflowY="auto"
|
||||
justifyContent="space-between"
|
||||
position="sticky"
|
||||
top="0"
|
||||
backgroundColor
|
||||
>
|
||||
{children}
|
||||
<Column gap overflowY="auto" justifyContent="space-between">
|
||||
{title && (
|
||||
<Row padding>
|
||||
<Heading size="1">{title}</Heading>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export function useFields() {
|
|||
|
||||
const fields = [
|
||||
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
||||
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
|
||||
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
||||
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
||||
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { useState, Key, Fragment } from 'react';
|
||||
import { Modal, Select, ListItem, ListSeparator, Dialog, SelectProps } from '@umami/react-zen';
|
||||
import {
|
||||
Modal,
|
||||
Select,
|
||||
ListItem,
|
||||
ListSeparator,
|
||||
Dialog,
|
||||
SelectProps,
|
||||
useBreakpoint,
|
||||
} from '@umami/react-zen';
|
||||
import { endOfYear } from 'date-fns';
|
||||
import { DatePickerForm } from '@/components/metrics/DatePickerForm';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
|
@ -11,7 +19,7 @@ export interface DateFilterProps extends SelectProps {
|
|||
onChange?: (value: string) => void;
|
||||
showAllTime?: boolean;
|
||||
renderDate?: boolean;
|
||||
placement?: string;
|
||||
placement?: any;
|
||||
}
|
||||
|
||||
export function DateFilter({
|
||||
|
|
@ -25,6 +33,8 @@ export function DateFilter({
|
|||
const { formatMessage, labels } = useMessages();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const { startDate, endDate } = parseDateRange(value) || {};
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
|
||||
|
||||
const options = [
|
||||
{ label: formatMessage(labels.today), value: '0day' },
|
||||
|
|
@ -109,7 +119,7 @@ export function DateFilter({
|
|||
placeholder={formatMessage(labels.selectDate)}
|
||||
onChange={handleChange}
|
||||
renderValue={renderValue}
|
||||
popoverProps={{ placement: placement as any }}
|
||||
popoverProps={{ placement, isNonModal: isMobile }}
|
||||
>
|
||||
{options.map(({ label, value, divider }: any) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import { Key } from 'react';
|
||||
import { subMonths, endOfDay } from 'date-fns';
|
||||
import { Grid, Column, List, ListItem } from '@umami/react-zen';
|
||||
import {
|
||||
Grid,
|
||||
Column,
|
||||
List,
|
||||
ListItem,
|
||||
Row,
|
||||
Button,
|
||||
Popover,
|
||||
MenuTrigger,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Icon,
|
||||
} from '@umami/react-zen';
|
||||
import { useFields, useMessages } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
|
||||
|
|
@ -39,8 +52,31 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
|
|||
};
|
||||
|
||||
return (
|
||||
<Grid columns="160px 1fr" overflow="hidden" gapY="6">
|
||||
<Column border="right" paddingRight="3">
|
||||
<Grid columns={{ xs: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
|
||||
<Row display={{ xs: 'flex', md: 'none' }}>
|
||||
<MenuTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom start">
|
||||
<Menu onAction={handleAdd}>
|
||||
{fields
|
||||
.filter(({ name }) => !exclude.includes(name))
|
||||
.map(field => {
|
||||
const isDisabled = !!value.find(({ name }) => name === field.name);
|
||||
return (
|
||||
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
|
||||
{field.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
</Row>
|
||||
<Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
|
||||
<List onAction={handleAdd}>
|
||||
{fields
|
||||
.filter(({ name }) => !exclude.includes(name))
|
||||
|
|
@ -54,7 +90,7 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
|
|||
})}
|
||||
</List>
|
||||
</Column>
|
||||
<Column paddingLeft="6" overflow="auto" gapY="4" height="500px" style={{ contain: 'layout' }}>
|
||||
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
|
||||
{value.map(filter => {
|
||||
return (
|
||||
<FilterRecord
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useFilters, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
||||
import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||
import { Button, Column, Row, Tab, TabList, TabPanel, Tabs, useBreakpoint } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface FilterEditFormProps {
|
||||
|
|
@ -20,9 +20,8 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
|||
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||
const [currentSegment, setCurrentSegment] = useState(segment);
|
||||
const [currentCohort, setCurrentCohort] = useState(cohort);
|
||||
const panelProps = {
|
||||
style: { height: 500 },
|
||||
};
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
|
||||
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
|
||||
|
||||
const handleReset = () => {
|
||||
|
|
@ -46,42 +45,44 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
|||
};
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="fields">{formatMessage(labels.fields)}</Tab>
|
||||
{!excludeFilters && (
|
||||
<>
|
||||
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
||||
<Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
|
||||
</>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel id="fields" {...panelProps}>
|
||||
<FieldFilters
|
||||
websiteId={websiteId}
|
||||
value={currentFilters}
|
||||
onChange={setCurrentFilters}
|
||||
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="segments" {...panelProps}>
|
||||
<SegmentFilters
|
||||
websiteId={websiteId}
|
||||
segmentId={currentSegment}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="cohorts" {...panelProps}>
|
||||
<SegmentFilters
|
||||
type="cohort"
|
||||
websiteId={websiteId}
|
||||
segmentId={currentCohort}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Row alignItems="center" justifyContent="space-between" gridColumn="span 2" marginTop="6" gap>
|
||||
<Column width={isMobile ? 'auto' : '800px'} gap="6">
|
||||
<Column minHeight="500px">
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="fields">{formatMessage(labels.fields)}</Tab>
|
||||
{!excludeFilters && (
|
||||
<>
|
||||
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
||||
<Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
|
||||
</>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel id="fields">
|
||||
<FieldFilters
|
||||
websiteId={websiteId}
|
||||
value={currentFilters}
|
||||
onChange={setCurrentFilters}
|
||||
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="segments">
|
||||
<SegmentFilters
|
||||
websiteId={websiteId}
|
||||
segmentId={currentSegment}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="cohorts">
|
||||
<SegmentFilters
|
||||
type="cohort"
|
||||
websiteId={websiteId}
|
||||
segmentId={currentCohort}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Column,
|
||||
Pressable,
|
||||
IconLabel,
|
||||
useBreakpoint,
|
||||
} from '@umami/react-zen';
|
||||
import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
import {
|
||||
|
|
@ -39,9 +40,11 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
|
|||
const { cloudMode } = useConfig();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { teamId } = useNavigation();
|
||||
const breakpoint = useBreakpoint();
|
||||
const team = user?.teams?.find(({ id }) => id === teamId);
|
||||
const selectedKeys = new Set([teamId || 'user']);
|
||||
const label = teamId ? team?.name : user.username;
|
||||
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
|
||||
|
||||
const getUrl = (url: string) => {
|
||||
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
|
||||
|
|
@ -82,7 +85,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
|
|||
<MenuItem id="teams" showChecked={false} showSubMenuIcon>
|
||||
<IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
|
||||
</MenuItem>
|
||||
<Popover placement="right top">
|
||||
<Popover placement={isMobile ? 'bottom start' : 'right top'}>
|
||||
<Column minWidth="300px">
|
||||
<Menu selectionMode="single" selectedKeys={selectedKeys}>
|
||||
<MenuSection title={formatMessage(labels.myAccount)}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Icon, DialogTrigger, Dialog, Popover, Text } from '@umami/react-zen';
|
||||
import { Button, Icon, DialogTrigger, Dialog, Text, Modal, useBreakpoint } from '@umami/react-zen';
|
||||
import { ListFilter } from '@/components/icons';
|
||||
import { FilterEditForm } from '@/components/input/FilterEditForm';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
|
|
@ -15,6 +15,8 @@ export function WebsiteFilterButton({
|
|||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updateParams, router } = useNavigation();
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
|
||||
|
||||
const handleChange = ({ filters, segment, cohort }: any) => {
|
||||
const params = filtersArrayToObject(filters);
|
||||
|
|
@ -32,13 +34,13 @@ export function WebsiteFilterButton({
|
|||
</Icon>
|
||||
{showText && <Text>{formatMessage(labels.filter)}</Text>}
|
||||
</Button>
|
||||
<Popover placement="bottom start">
|
||||
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}>
|
||||
<Modal position={isMobile ? 'fullscreen' : 'center'}>
|
||||
<Dialog title={formatMessage(labels.filters)}>
|
||||
{({ close }) => {
|
||||
return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
|
||||
}}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,23 +69,38 @@ export function MetricsExpandedTable({
|
|||
height="100%"
|
||||
loadingIcon="spinner"
|
||||
>
|
||||
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
||||
<Column overflow="auto" minHeight="0" height="100%" paddingRight="3">
|
||||
{items && (
|
||||
<DataTable data={items}>
|
||||
<DataColumn id="label" label={title} width="2fr" align="start">
|
||||
<DataColumn id="label" label={title} width="minmax(200px, 2fr)" align="start">
|
||||
{row => (
|
||||
<Row overflow="hidden">
|
||||
<MetricLabel type={type} data={row} />
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||
<DataColumn
|
||||
id="visitors"
|
||||
label={formatMessage(labels.visitors)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => row?.['visitors']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||
<DataColumn
|
||||
id="visits"
|
||||
label={formatMessage(labels.visits)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => row?.['visits']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="pageviews" label={formatMessage(labels.views)} align="end">
|
||||
<DataColumn
|
||||
id="pageviews"
|
||||
label={formatMessage(labels.views)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => row?.['pageviews']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
{showBounceDuration && [
|
||||
|
|
@ -94,6 +109,7 @@ export function MetricsExpandedTable({
|
|||
id="bounceRate"
|
||||
label={formatMessage(labels.bounceRate)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => {
|
||||
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||
|
|
@ -106,6 +122,7 @@ export function MetricsExpandedTable({
|
|||
id="visitDuration"
|
||||
label={formatMessage(labels.visitDuration)}
|
||||
align="end"
|
||||
width="120px"
|
||||
>
|
||||
{row => {
|
||||
const n = row?.['totaltime'] / row?.['visits'];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue