Responsive everything.

This commit is contained in:
Mike Cao 2025-10-16 02:42:13 -07:00
parent 9df012084d
commit 4d2a7ea947
23 changed files with 286 additions and 287 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "@umami/components", "name": "@umami/components",
"version": "0.129.0", "version": "0.130.0",
"description": "Umami React components.", "description": "Umami React components.",
"author": "Mike Cao <mike@mikecao.com>", "author": "Mike Cao <mike@mikecao.com>",
"license": "MIT", "license": "MIT",

View file

@ -78,7 +78,7 @@
"@react-spring/web": "^10.0.3", "@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@umami/react-zen": "^0.196.0", "@umami/react-zen": "^0.198.0",
"@umami/redis-client": "^0.29.0", "@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",

12
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.90.2 specifier: ^5.90.2
version: 5.90.2(react@19.1.1) version: 5.90.2(react@19.1.1)
'@umami/react-zen': '@umami/react-zen':
specifier: ^0.196.0 specifier: ^0.198.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)) 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': '@umami/redis-client':
specifier: ^0.29.0 specifier: ^0.29.0
version: 0.29.0 version: 0.29.0
@ -364,6 +364,8 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
dist: {}
packages: packages:
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':
@ -2754,8 +2756,8 @@ packages:
resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==} resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.196.0': '@umami/react-zen@0.198.0':
resolution: {integrity: sha512-CLxrDAJOdo+0aJAclOq7naIDg+2I5wP9wXxAFhxhQVPXHV8yUHqH9Ula632cLMo51JYp0l+eEtOtuimpuKX3jg==} resolution: {integrity: sha512-usxXJPz5XnHzJ3Qo2RD0UZw+De/1QOx1f0SJDZNx8TZGLBNFqUHTAZgwOZANP7JgkS9cd4Q+WSobqP4JOp2saQ==}
'@umami/redis-client@0.29.0': '@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==} resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -10385,7 +10387,7 @@ snapshots:
'@typescript-eslint/types': 8.45.0 '@typescript-eslint/types': 8.45.0
eslint-visitor-keys: 4.2.1 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: dependencies:
'@fontsource/jetbrains-mono': 5.2.8 '@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0

View file

@ -1,22 +1,11 @@
import { import { Row, NavMenu, NavMenuItem, IconLabel, Text, Grid } from '@umami/react-zen';
Row, import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
Dialog,
DialogTrigger,
Button,
Icon,
Modal,
NavMenu,
NavMenuItem,
IconLabel,
Text,
Grid,
} from '@umami/react-zen';
import { Globe, Grid2x2, LinkIcon, Menu } from '@/components/icons';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import Link from 'next/link'; import Link from 'next/link';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav'; import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { Logo } from '@/components/svg'; import { Logo } from '@/components/svg';
import { NavButton } from '@/components/input/NavButton'; import { NavButton } from '@/components/input/NavButton';
import { MobileMenu } from '@/components/common/MobileMenu';
export function MobileNav() { export function MobileNav() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -45,30 +34,27 @@ export function MobileNav() {
return ( return (
<Grid columns="auto 1fr" flexGrow={1}> <Grid columns="auto 1fr" flexGrow={1}>
<DialogTrigger> <MobileMenu>
<Button> {({ close }) => {
<Icon> return (
<Menu /> <>
</Icon> <NavMenu padding="3" onItemClick={close} border="bottom">
</Button> <NavButton />
<Modal position="left" offset="80px"> {links.map(link => {
<Dialog variant="sheet"> return (
<NavMenu padding="3"> <Link key={link.id} href={link.path}>
<NavButton /> <NavMenuItem>
{links.map(link => { <IconLabel icon={link.icon} label={link.label} />
return ( </NavMenuItem>
<Link key={link.id} href={link.path}> </Link>
<NavMenuItem> );
<IconLabel icon={link.icon} label={link.label} /> })}
</NavMenuItem> </NavMenu>
</Link> {websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
); </>
})} );
</NavMenu> }}
{websiteId && <WebsiteNav websiteId={websiteId} />} </MobileMenu>
</Dialog>
</Modal>
</DialogTrigger>
<Row alignItems="center" justifyContent="center" flexGrow={1}> <Row alignItems="center" justifyContent="center" flexGrow={1}>
<IconLabel icon={<Logo />} style={{ width: 'auto' }}> <IconLabel icon={<Logo />} style={{ width: 'auto' }}>
<Text weight="bold">umami</Text> <Text weight="bold">umami</Text>

View file

@ -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 { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
import { useNavigation } from '@/components/hooks'; import { useNavigation } from '@/components/hooks';
@ -14,6 +14,8 @@ export function ExpandedViewModal({
query: { view }, query: { view },
updateParams, updateParams,
} = useNavigation(); } = useNavigation();
const breakpoint = useBreakpoint();
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
const handleClose = (close: () => void) => { const handleClose = (close: () => void) => {
router.push(updateParams({ view: undefined })); router.push(updateParams({ view: undefined }));
@ -28,7 +30,13 @@ export function ExpandedViewModal({
return ( return (
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable> <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 }) => { {({ close }) => {
return ( return (
<WebsiteExpandedView <WebsiteExpandedView

View file

@ -22,7 +22,13 @@ import {
} from '@/components/icons'; } from '@/components/icons';
import { Lightning } from '@/components/svg'; 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 { formatMessage, labels } = useMessages();
const { const {
updateParams, 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} />;
} }

View file

@ -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 { useMessages, useNavigation } from '@/components/hooks';
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable'; import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu'; import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu';
import { MobileMenu } from '@/components/common/MobileMenu';
export function WebsiteExpandedView({ export function WebsiteExpandedView({
websiteId, websiteId,
@ -18,24 +19,33 @@ export function WebsiteExpandedView({
} = useNavigation(); } = useNavigation();
return ( return (
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden"> <Column gap>
<Column <Row display={{ xs: 'flex', md: 'none' }}>
display={{ xs: 'none', md: 'flex' }} <MobileMenu>
gap="6" {({ close }) => {
border="right" return <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />;
paddingRight="3" }}
overflowY="auto" </MobileMenu>
> </Row>
<WebsiteExpandedMenu excludedIds={excludedIds} /> <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" height="100%" overflow="hidden">
</Column> <Column
<Column overflow="hidden"> display={{ xs: 'none', md: 'flex' }}
<MetricsExpandedTable gap="6"
title={formatMessage(labels[view])} border="right"
type={view} paddingRight="3"
websiteId={websiteId} overflow="auto"
onClose={onClose} >
/> <WebsiteExpandedMenu excludedIds={excludedIds} />
</Column> </Column>
</Grid> <Column overflow="hidden">
<MetricsExpandedTable
title={formatMessage(labels[view])}
type={view}
websiteId={websiteId}
onClose={onClose}
/>
</Column>
</Grid>
</Column>
); );
} }

View file

@ -1,4 +1,4 @@
import { Text } from '@umami/react-zen'; import { Text, Column } from '@umami/react-zen';
import { import {
Eye, Eye,
User, User,
@ -14,7 +14,13 @@ import { useMessages, useNavigation } from '@/components/hooks';
import { SideMenu } from '@/components/common/SideMenu'; import { SideMenu } from '@/components/common/SideMenu';
import { WebsiteSelect } from '@/components/input/WebsiteSelect'; 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 { formatMessage, labels } = useMessages();
const { pathname, renderUrl, teamId, router } = useNavigation(); 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; .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return ( return (
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}> <Column padding="3" gap>
<WebsiteSelect <WebsiteSelect
websiteId={websiteId} websiteId={websiteId}
teamId={teamId} teamId={teamId}
@ -163,6 +169,12 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
renderValue={renderValue} renderValue={renderValue}
buttonProps={{ style: { outline: 'none' } }} buttonProps={{ style: { outline: 'none' } }}
/> />
</SideMenu> <SideMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
); );
} }

View file

@ -1,9 +1,8 @@
import { ReactNode } from 'react'; 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 { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
import { TypeIcon } from '@/components/common/TypeIcon'; import { TypeIcon } from '@/components/common/TypeIcon';
import { KeyRound, Calendar } from '@/components/icons'; import { KeyRound, Calendar, MapPin, Landmark } from '@/components/icons';
import { Location } from '@/components/svg';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
export function SessionInfo({ data }) { export function SessionInfo({ data }) {
@ -13,11 +12,7 @@ export function SessionInfo({ data }) {
const { getRegionName } = useRegionNames(locale); const { getRegionName } = useRegionNames(locale);
return ( return (
<Column gap="6"> <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap>
<Info label="ID">
<TextField value={data?.id} style={{ width: '100%' }} allowCopy />
</Info>
<Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}> <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
{data?.distinctId} {data?.distinctId}
</Info> </Info>
@ -37,11 +32,11 @@ export function SessionInfo({ data }) {
{formatValue(data?.country, 'country')} {formatValue(data?.country, 'country')}
</Info> </Info>
<Info label={formatMessage(labels.region)} icon={<Location />}> <Info label={formatMessage(labels.region)} icon={<MapPin />}>
{getRegionName(data?.region)} {getRegionName(data?.region)}
</Info> </Info>
<Info label={formatMessage(labels.city)} icon={<Location />}> <Info label={formatMessage(labels.city)} icon={<Landmark />}>
{data?.city} {data?.city}
</Info> </Info>
@ -65,7 +60,7 @@ export function SessionInfo({ data }) {
> >
{formatValue(data?.device, 'device')} {formatValue(data?.device, 'device')}
</Info> </Info>
</Column> </Grid>
); );
} }

View file

@ -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 { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { X } from '@/components/icons'; import { X } from '@/components/icons';
@ -37,35 +47,34 @@ export function SessionProfile({
</Icon> </Icon>
</Button> </Button>
</Row> </Row>
<Grid columns="340px 1fr" gap="6"> <Column gap="6">
<Column gap="6"> <Row justifyContent="center" alignItems="center" gap="6">
<Row justifyContent="center"> <Avatar seed={data?.id} size={128} />
<Avatar seed={data?.id} size={128} /> <Column width="360px">
</Row> <TextField label="ID" value={data?.id} allowCopy />
<SessionInfo data={data} /> </Column>
</Column> </Row>
<Column gap> <SessionStats data={data} />
<SessionStats data={data} /> <SessionInfo data={data} />
<Tabs> <Tabs>
<TabList> <TabList>
<Tab id="activity">{formatMessage(labels.activity)}</Tab> <Tab id="activity">{formatMessage(labels.activity)}</Tab>
<Tab id="properties">{formatMessage(labels.properties)}</Tab> <Tab id="properties">{formatMessage(labels.properties)}</Tab>
</TabList> </TabList>
<TabPanel id="activity"> <TabPanel id="activity">
<SessionActivity <SessionActivity
websiteId={websiteId} websiteId={websiteId}
sessionId={sessionId} sessionId={sessionId}
startDate={data?.firstAt} startDate={data?.firstAt}
endDate={data?.lastAt} endDate={data?.lastAt}
/> />
</TabPanel> </TabPanel>
<TabPanel id="properties"> <TabPanel id="properties">
<SessionData sessionId={sessionId} websiteId={websiteId} /> <SessionData sessionId={sessionId} websiteId={websiteId} />
</TabPanel> </TabPanel>
</Tabs> </Tabs>
</Column> </Column>
</Grid>
</Column> </Column>
)} )}
</LoadingPanel> </LoadingPanel>

View file

@ -65,11 +65,11 @@ export function FilterRecord({
<Column> <Column>
<Label>{fields.find(f => f.name === name)?.label}</Label> <Label>{fields.find(f => f.name === name)?.label}</Label>
<Grid columns="1fr auto" gap> <Grid columns="1fr auto" gap>
<Grid columns="200px 1fr" gap> <Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap>
<Select <Select
items={operators.filter(({ type }) => type === 'string')} items={operators.filter(({ type }) => type === 'string')}
value={operator} value={operator}
onSelectionChange={handleSelectOperator} onChange={handleSelectOperator}
> >
{({ name, label }: any) => { {({ name, label }: any) => {
return ( return (

View file

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

View file

@ -26,7 +26,7 @@ export function LoadingPanel({
renderEmpty = () => <Empty />, renderEmpty = () => <Empty />,
children, children,
...props ...props
}: LoadingPanelProps) { }: LoadingPanelProps): ReactNode {
const empty = isEmpty ?? checkEmpty(data); const empty = isEmpty ?? checkEmpty(data);
// Show loading spinner only if no data exists // Show loading spinner only if no data exists

View file

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

View file

@ -1,45 +1,17 @@
import { createPortal } from 'react-dom'; import { Dialog, DialogTrigger, Button, Icon, Modal, DialogProps } from '@umami/react-zen';
import classNames from 'classnames'; import { Menu } from '@/components/icons';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import styles from './MobileMenu.module.css';
export function MobileMenu({ export function MobileMenu(props: DialogProps) {
items = [], return (
onClose, <DialogTrigger>
}: { <Button>
items: any[]; <Icon>
className?: string; <Menu />
onClose: () => void; </Icon>
}): any { </Button>
const pathname = usePathname(); <Modal position="left" offset="80px">
<Dialog variant="sheet" {...props} />
const Items = ({ items, className }: { items: any[]; className?: string }): any => ( </Modal>
<div className={classNames(styles.items, className)}> </DialogTrigger>
{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,
); );
} }

View file

@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { import {
Heading, Heading,
NavMenu, NavMenu,
@ -28,7 +27,6 @@ export interface SideMenuProps extends NavMenuProps {
title?: string; title?: string;
selectedKey?: string; selectedKey?: string;
allowMinimize?: boolean; allowMinimize?: boolean;
children?: ReactNode;
} }
export function SideMenu({ export function SideMenu({
@ -36,7 +34,6 @@ export function SideMenu({
title, title,
selectedKey, selectedKey,
allowMinimize, allowMinimize,
children,
...props ...props
}: SideMenuProps) { }: SideMenuProps) {
const renderItems = (items: SideMenuData[]) => { const renderItems = (items: SideMenuData[]) => {
@ -54,16 +51,7 @@ export function SideMenu({
}; };
return ( return (
<Column <Column gap overflowY="auto" justifyContent="space-between">
gap
padding
overflowY="auto"
justifyContent="space-between"
position="sticky"
top="0"
backgroundColor
>
{children}
{title && ( {title && (
<Row padding> <Row padding>
<Heading size="1">{title}</Heading> <Heading size="1">{title}</Heading>

View file

@ -5,6 +5,7 @@ export function useFields() {
const fields = [ const fields = [
{ name: 'path', type: 'string', label: formatMessage(labels.path) }, { 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: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) }, { name: 'browser', type: 'string', label: formatMessage(labels.browser) },

View file

@ -1,5 +1,13 @@
import { useState, Key, Fragment } from 'react'; 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 { endOfYear } from 'date-fns';
import { DatePickerForm } from '@/components/metrics/DatePickerForm'; import { DatePickerForm } from '@/components/metrics/DatePickerForm';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
@ -11,7 +19,7 @@ export interface DateFilterProps extends SelectProps {
onChange?: (value: string) => void; onChange?: (value: string) => void;
showAllTime?: boolean; showAllTime?: boolean;
renderDate?: boolean; renderDate?: boolean;
placement?: string; placement?: any;
} }
export function DateFilter({ export function DateFilter({
@ -25,6 +33,8 @@ export function DateFilter({
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const { startDate, endDate } = parseDateRange(value) || {}; const { startDate, endDate } = parseDateRange(value) || {};
const breakpoint = useBreakpoint();
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
const options = [ const options = [
{ label: formatMessage(labels.today), value: '0day' }, { label: formatMessage(labels.today), value: '0day' },
@ -109,7 +119,7 @@ export function DateFilter({
placeholder={formatMessage(labels.selectDate)} placeholder={formatMessage(labels.selectDate)}
onChange={handleChange} onChange={handleChange}
renderValue={renderValue} renderValue={renderValue}
popoverProps={{ placement: placement as any }} popoverProps={{ placement, isNonModal: isMobile }}
> >
{options.map(({ label, value, divider }: any) => { {options.map(({ label, value, divider }: any) => {
return ( return (

View file

@ -1,7 +1,20 @@
import { Key } from 'react'; import { Key } from 'react';
import { subMonths, endOfDay } from 'date-fns'; 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 { useFields, useMessages } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { FilterRecord } from '@/components/common/FilterRecord'; import { FilterRecord } from '@/components/common/FilterRecord';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
@ -39,8 +52,31 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
}; };
return ( return (
<Grid columns="160px 1fr" overflow="hidden" gapY="6"> <Grid columns={{ xs: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
<Column border="right" paddingRight="3"> <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}> <List onAction={handleAdd}>
{fields {fields
.filter(({ name }) => !exclude.includes(name)) .filter(({ name }) => !exclude.includes(name))
@ -54,7 +90,7 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
})} })}
</List> </List>
</Column> </Column>
<Column paddingLeft="6" overflow="auto" gapY="4" height="500px" style={{ contain: 'layout' }}> <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
{value.map(filter => { {value.map(filter => {
return ( return (
<FilterRecord <FilterRecord

View file

@ -1,7 +1,7 @@
import { useFilters, useMessages, useNavigation } from '@/components/hooks'; import { useFilters, useMessages, useNavigation } from '@/components/hooks';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
import { SegmentFilters } from '@/components/input/SegmentFilters'; 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'; import { useState } from 'react';
export interface FilterEditFormProps { export interface FilterEditFormProps {
@ -20,9 +20,8 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
const [currentFilters, setCurrentFilters] = useState(filters); const [currentFilters, setCurrentFilters] = useState(filters);
const [currentSegment, setCurrentSegment] = useState(segment); const [currentSegment, setCurrentSegment] = useState(segment);
const [currentCohort, setCurrentCohort] = useState(cohort); const [currentCohort, setCurrentCohort] = useState(cohort);
const panelProps = { const breakpoint = useBreakpoint();
style: { height: 500 }, const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
};
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
const handleReset = () => { const handleReset = () => {
@ -46,42 +45,44 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
}; };
return ( return (
<Column> <Column width={isMobile ? 'auto' : '800px'} gap="6">
<Tabs> <Column minHeight="500px">
<TabList> <Tabs>
<Tab id="fields">{formatMessage(labels.fields)}</Tab> <TabList>
{!excludeFilters && ( <Tab id="fields">{formatMessage(labels.fields)}</Tab>
<> {!excludeFilters && (
<Tab id="segments">{formatMessage(labels.segments)}</Tab> <>
<Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab> <Tab id="segments">{formatMessage(labels.segments)}</Tab>
</> <Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
)} </>
</TabList> )}
<TabPanel id="fields" {...panelProps}> </TabList>
<FieldFilters <TabPanel id="fields">
websiteId={websiteId} <FieldFilters
value={currentFilters} websiteId={websiteId}
onChange={setCurrentFilters} value={currentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} onChange={setCurrentFilters}
/> exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
</TabPanel> />
<TabPanel id="segments" {...panelProps}> </TabPanel>
<SegmentFilters <TabPanel id="segments">
websiteId={websiteId} <SegmentFilters
segmentId={currentSegment} websiteId={websiteId}
onChange={handleSegmentChange} segmentId={currentSegment}
/> onChange={handleSegmentChange}
</TabPanel> />
<TabPanel id="cohorts" {...panelProps}> </TabPanel>
<SegmentFilters <TabPanel id="cohorts">
type="cohort" <SegmentFilters
websiteId={websiteId} type="cohort"
segmentId={currentCohort} websiteId={websiteId}
onChange={handleSegmentChange} segmentId={currentCohort}
/> onChange={handleSegmentChange}
</TabPanel> />
</Tabs> </TabPanel>
<Row alignItems="center" justifyContent="space-between" gridColumn="span 2" marginTop="6" gap> </Tabs>
</Column>
<Row alignItems="center" justifyContent="space-between" gap>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap> <Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>

View file

@ -12,6 +12,7 @@ import {
Column, Column,
Pressable, Pressable,
IconLabel, IconLabel,
useBreakpoint,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks'; import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { import {
@ -39,9 +40,11 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
const { cloudMode } = useConfig(); const { cloudMode } = useConfig();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const breakpoint = useBreakpoint();
const team = user?.teams?.find(({ id }) => id === teamId); const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']); const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username; const label = teamId ? team?.name : user.username;
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
const getUrl = (url: string) => { const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}${url}` : url; return cloudMode ? `${process.env.cloudUrl}${url}` : url;
@ -82,7 +85,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
<MenuItem id="teams" showChecked={false} showSubMenuIcon> <MenuItem id="teams" showChecked={false} showSubMenuIcon>
<IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} /> <IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
</MenuItem> </MenuItem>
<Popover placement="right top"> <Popover placement={isMobile ? 'bottom start' : 'right top'}>
<Column minWidth="300px"> <Column minWidth="300px">
<Menu selectionMode="single" selectedKeys={selectedKeys}> <Menu selectionMode="single" selectedKeys={selectedKeys}>
<MenuSection title={formatMessage(labels.myAccount)}> <MenuSection title={formatMessage(labels.myAccount)}>

View file

@ -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 { ListFilter } from '@/components/icons';
import { FilterEditForm } from '@/components/input/FilterEditForm'; import { FilterEditForm } from '@/components/input/FilterEditForm';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
@ -15,6 +15,8 @@ export function WebsiteFilterButton({
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { updateParams, router } = useNavigation(); const { updateParams, router } = useNavigation();
const breakpoint = useBreakpoint();
const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
const handleChange = ({ filters, segment, cohort }: any) => { const handleChange = ({ filters, segment, cohort }: any) => {
const params = filtersArrayToObject(filters); const params = filtersArrayToObject(filters);
@ -32,13 +34,13 @@ export function WebsiteFilterButton({
</Icon> </Icon>
{showText && <Text>{formatMessage(labels.filter)}</Text>} {showText && <Text>{formatMessage(labels.filter)}</Text>}
</Button> </Button>
<Popover placement="bottom start"> <Modal position={isMobile ? 'fullscreen' : 'center'}>
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}> <Dialog title={formatMessage(labels.filters)}>
{({ close }) => { {({ close }) => {
return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />; return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
}} }}
</Dialog> </Dialog>
</Popover> </Modal>
</DialogTrigger> </DialogTrigger>
); );
} }

View file

@ -69,23 +69,38 @@ export function MetricsExpandedTable({
height="100%" height="100%"
loadingIcon="spinner" loadingIcon="spinner"
> >
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden"> <Column overflow="auto" minHeight="0" height="100%" paddingRight="3">
{items && ( {items && (
<DataTable data={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 => (
<Row overflow="hidden"> <Row overflow="hidden">
<MetricLabel type={type} data={row} /> <MetricLabel type={type} data={row} />
</Row> </Row>
)} )}
</DataColumn> </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()} {row => row?.['visitors']?.toLocaleString()}
</DataColumn> </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()} {row => row?.['visits']?.toLocaleString()}
</DataColumn> </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()} {row => row?.['pageviews']?.toLocaleString()}
</DataColumn> </DataColumn>
{showBounceDuration && [ {showBounceDuration && [
@ -94,6 +109,7 @@ export function MetricsExpandedTable({
id="bounceRate" id="bounceRate"
label={formatMessage(labels.bounceRate)} label={formatMessage(labels.bounceRate)}
align="end" align="end"
width="120px"
> >
{row => { {row => {
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100; const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
@ -106,6 +122,7 @@ export function MetricsExpandedTable({
id="visitDuration" id="visitDuration"
label={formatMessage(labels.visitDuration)} label={formatMessage(labels.visitDuration)}
align="end" align="end"
width="120px"
> >
{row => { {row => {
const n = row?.['totaltime'] / row?.['visits']; const n = row?.['totaltime'] / row?.['visits'];