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

@ -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 (

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 />,
children,
...props
}: LoadingPanelProps) {
}: LoadingPanelProps): ReactNode {
const empty = isEmpty ?? checkEmpty(data);
// 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 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>
);
}

View file

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

View file

@ -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) },

View file

@ -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 (

View file

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

View file

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

View file

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

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

View file

@ -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'];