Updated layout. Fixed properties rendering.

This commit is contained in:
Mike Cao 2025-07-18 00:22:06 -07:00
parent 01bfd7f52e
commit 876f4c883e
18 changed files with 214 additions and 176 deletions

View file

@ -82,7 +82,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.80.10",
"@umami/react-zen": "^0.148.0",
"@umami/react-zen": "^0.150.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",

10
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.80.10
version: 5.80.10(react@19.1.0)
'@umami/react-zen':
specifier: ^0.148.0
version: 0.148.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
specifier: ^0.150.0
version: 0.150.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
'@umami/redis-client':
specifier: ^0.27.0
version: 0.27.0
@ -2553,8 +2553,8 @@ packages:
resolution: {integrity: sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.148.0':
resolution: {integrity: sha512-4bI8wBgqep6bYNsnhQ87lSAYqiKLj/9o1emFfIot0NIxKd2jsmgEwGrymxDjmunXK4OwRs8ukOYvzuH3WbieGA==}
'@umami/react-zen@0.150.0':
resolution: {integrity: sha512-ogtdNm7jg7BnzWnQtchOAE1eE36EuxRLZaoalPLm8VrK4eQweqnbRFw+ddeAA12xI0hPP8gKOxOJDhH9pJpVVg==}
'@umami/redis-client@0.27.0':
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
@ -9739,7 +9739,7 @@ snapshots:
'@typescript-eslint/types': 8.34.1
eslint-visitor-keys: 4.2.1
'@umami/react-zen@0.148.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
'@umami/react-zen@0.150.0(@babel/core@7.27.1)(@types/react@19.1.8)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.6
'@internationalized/date': 3.8.2

View file

@ -1,5 +1,5 @@
'use client';
import { Grid, Loading, Column, Row } from '@umami/react-zen';
import { Grid, Loading, Column } from '@umami/react-zen';
import Script from 'next/script';
import { usePathname } from 'next/navigation';
import { UpdateNotice } from './UpdateNotice';
@ -30,20 +30,12 @@ export function App({ children }) {
}
return (
<Grid height="100vh" width="100%" columns="auto 1fr" rows="auto 1fr" backgroundColor="2">
<Column gridColumn="1 / 2" gridRow="1 / 3" backgroundColor>
<Grid height="100vh" width="100%" columns="auto 1fr" backgroundColor="2">
<Column>
<SideNav />
</Column>
<Row gridColumn="2 / 3" gridRow="1 / 2">
<Column alignItems="center" overflow="auto" position="relative">
<TopNav />
</Row>
<Column
gridColumn="2 / 3"
gridRow="2 / 3"
alignItems="center"
overflow="auto"
position="relative"
>
{children}
</Column>
<UpdateNotice user={user} config={config} />

View file

@ -1,12 +1,11 @@
import Link from 'next/link';
import {
Sidebar,
SidebarHeader,
SidebarSection,
SidebarItem,
Button,
Icon,
SidebarHeader,
Row,
SidebarProps,
} from '@umami/react-zen';
import {
Globe,
@ -16,14 +15,14 @@ import {
Grid2X2,
Settings,
LockKeyhole,
PanelLeft,
} from '@/components/icons';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
export function SideNav(props: any) {
export function SideNav(props: SidebarProps) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl } = useNavigation();
const [isCollapsed, setCollapsed] = useGlobalState('sidenav-collapsed');
const { pathname, renderUrl, websiteId } = useNavigation();
const [isCollapsed] = useGlobalState('sidenav-collapsed');
const links = [
{
@ -68,37 +67,37 @@ export function SideNav(props: any) {
];
return (
<Sidebar {...props} isCollapsed={isCollapsed} variant="quiet" showBorder={true}>
<SidebarSection>
<SidebarHeader label="umami" icon={<Logo />} />
</SidebarSection>
<SidebarSection flexGrow={1}>
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.endsWith(path)} />
</Link>
);
})}
</SidebarSection>
<SidebarSection>
{bottomLinks.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={path} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.startsWith(path)} />
</Link>
);
})}
</SidebarSection>
<SidebarSection>
<Row>
<Button onPress={() => setCollapsed(!isCollapsed)} variant="quiet">
<Icon>
<PanelLeft />
</Icon>
</Button>
</Row>
</SidebarSection>
</Sidebar>
<Row height="100%" margin="2" backgroundColor border borderRadius>
<Sidebar
{...props}
isCollapsed={isCollapsed || websiteId}
muteItems={false}
variant="quiet"
showBorder={false}
>
<SidebarSection>
<SidebarHeader label="umami" icon={<Logo />} />
</SidebarSection>
<SidebarSection flexGrow={1}>
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.endsWith(path)} />
</Link>
);
})}
</SidebarSection>
<SidebarSection>
{bottomLinks.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={path} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.startsWith(path)} />
</Link>
);
})}
</SidebarSection>
</Sidebar>
{websiteId && <WebsiteNav websiteId={websiteId} />}
</Row>
);
}

View file

@ -5,6 +5,7 @@ import { TeamsButton } from '@/components/input/TeamsButton';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import { Slash } from '@/components/icons';
import { useNavigation } from '@/components/hooks';
import { PanelButton } from '@/components/input/PanelButton';
export function TopNav() {
const { teamId, websiteId, pathname } = useNavigation();
@ -14,21 +15,27 @@ export function TopNav() {
<Row
justifyContent="space-between"
alignItems="center"
paddingY="3"
paddingY="2"
paddingX="3"
paddingRight="5"
border="bottom"
width="100%"
style={{ position: 'sticky', top: 0 }}
backgroundColor="2"
zIndex={1}
>
<Row alignItems="center">
<PanelButton />
<Seperator />
<Row alignItems="center" gap="1">
<TeamsButton />
{websiteId && !isSettings && (
<>
<Icon strokeColor="7" rotate={-25}>
<Slash />
</Icon>
<WebsiteSelect variant="quiet" websiteId={websiteId} teamId={teamId} />
<Seperator />
<WebsiteSelect
buttonProps={{ variant: 'quiet' }}
websiteId={websiteId}
teamId={teamId}
/>
</>
)}
</Row>
@ -41,3 +48,11 @@ export function TopNav() {
</Row>
);
}
const Seperator = () => {
return (
<Icon strokeColor="7" rotate={-25}>
<Slash />
</Icon>
);
};

View file

@ -1,14 +1,13 @@
'use client';
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { useMessages, useNavigation } from '@/components/hooks';
import { SideMenu } from '@/components/common/SideMenu';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { PageBody } from '@/components/common/PageBody';
export function SettingsLayout({ children }: { children: ReactNode }) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
@ -23,13 +22,13 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
label: formatMessage(labels.profile),
url: '/settings/profile',
},
{ id: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
user.isAdmin && {
{
id: 'websites',
label: formatMessage(labels.websites),
url: '/settings/websites',
},
].filter(n => n);
{ id: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
];
const value = items.find(({ url }) => pathname.includes(url))?.id;

View file

@ -12,7 +12,7 @@ export function WebsiteHeader() {
const website = useWebsite();
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />}>
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
<Row alignItems="center" gap="6">
<ActiveUsers websiteId={website.id} />
<Row alignItems="center" gap>

View file

@ -1,25 +1,17 @@
'use client';
import { ReactNode } from 'react';
import { Column, Grid } from '@umami/react-zen';
import { Column } from '@umami/react-zen';
import { WebsiteProvider } from './WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { WebsiteHeader } from './WebsiteHeader';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return (
<WebsiteProvider websiteId={websiteId}>
<Grid columns="200px 1fr" gap width="100%">
<Column padding>
<WebsiteNav websiteId={websiteId} />
</Column>
<PageBody>
<Column gap>
<WebsiteHeader />
<Column>{children}</Column>
</Column>
</PageBody>
</Grid>
<PageBody gap>
<WebsiteHeader />
<Column>{children}</Column>
</PageBody>
</WebsiteProvider>
);
}

View file

@ -1,4 +1,4 @@
import { Icon, Text, Row, NavMenu, NavMenuItem, NavMenuGroup } from '@umami/react-zen';
import { Icon, Text, Row, NavMenu, NavMenuItem, NavMenuGroup, Column } from '@umami/react-zen';
import {
Eye,
Lightning,
@ -12,6 +12,8 @@ import {
Tag,
Money,
Network,
UserPlus,
ChartPie,
} from '@/components/icons';
import { useMessages, useNavigation } from '@/components/hooks';
import Link from 'next/link';
@ -22,7 +24,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
const links = [
{
label: formatMessage(labels.core),
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
@ -88,6 +90,18 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
icon: <Sheet />,
path: '/breakdown',
},
{
id: 'segments',
label: formatMessage(labels.segments),
icon: <ChartPie />,
path: '/segments',
},
{
id: 'cohorts',
label: formatMessage(labels.cohorts),
icon: <UserPlus />,
path: '/cohorts',
},
],
},
{
@ -120,27 +134,29 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
'overview';
return (
<NavMenu highlightColor="3" style={{ position: 'sticky', top: 'var(--spacing-2)' }}>
{links.map(({ label, items }) => {
return (
<NavMenuGroup title={label} key={label} gap="2">
{items.map(({ id, label, icon, path }) => {
const isSelected = selected === id;
<Column gap padding width="240px" border="left">
<NavMenu highlightColor="2">
{links.map(({ label, items }) => {
return (
<NavMenuGroup title={label} key={label} gap="1">
{items.map(({ id, label, icon, path }) => {
const isSelected = selected === id;
return (
<Link key={id} href={renderUrl(`/websites/${websiteId}${path}`)}>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Row>
</NavMenuItem>
</Link>
);
})}
</NavMenuGroup>
);
})}
</NavMenu>
return (
<Link key={id} href={renderUrl(`/websites/${websiteId}${path}`)}>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Row>
</NavMenuItem>
</Link>
);
})}
</NavMenuGroup>
);
})}
</NavMenu>
</Column>
);
}

View file

@ -28,41 +28,43 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
return (
<LoadingPanel
data={data}
isLoading={isLoading}
isFetching={isFetching}
data={data}
error={error}
minHeight="300px"
gap="6"
>
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={eventName}
onChange={setEventName}
placeholder=""
>
{events?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
<Select
label={formatMessage(labels.property)}
value={propertyName}
onChange={setPropertyName}
isDisabled={!eventName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
{propertyName && (
{data && (
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={eventName}
onChange={setEventName}
placeholder=""
>
{events?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
<Select
label={formatMessage(labels.property)}
value={propertyName}
onChange={setPropertyName}
isDisabled={!eventName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
)}
{eventName && propertyName && (
<EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
)}
</LoadingPanel>
@ -113,10 +115,12 @@ const EventValues = ({ websiteId, eventName, propertyName }) => {
minHeight="300px"
gap="6"
>
<Grid columns="1fr 1fr" gap>
{values && <ListTable title={propertyName} data={tableData} />}
<PieChart key={propertyName + eventName} type="doughnut" chartData={chartData} />
</Grid>
{values && (
<Grid columns="1fr 1fr" gap>
<ListTable title={propertyName} data={tableData} />
<PieChart type="doughnut" chartData={chartData} />
</Grid>
)}
</LoadingPanel>
);
};

View file

@ -26,20 +26,22 @@ export function SessionProperties({ websiteId }: { websiteId: string }) {
minHeight="300px"
gap="6"
>
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={propertyName}
onChange={setPropertyName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
{data && (
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
label={formatMessage(labels.event)}
value={propertyName}
onChange={setPropertyName}
placeholder=""
>
{properties?.map(p => (
<ListItem key={p} id={p}>
{p}
</ListItem>
))}
</Select>
</Grid>
)}
{propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
</LoadingPanel>
);
@ -84,10 +86,12 @@ const SessionValues = ({ websiteId, propertyName }) => {
minHeight="300px"
gap="6"
>
<Grid columns="1fr 1fr" gap>
{data && <ListTable title={propertyName} data={tableData} />}
<PieChart key={propertyName} type="doughnut" chartData={chartData} />
</Grid>
{data && (
<Grid columns="1fr 1fr" gap>
<ListTable title={propertyName} data={tableData} />
<PieChart type="doughnut" chartData={chartData} />
</Grid>
)}
</LoadingPanel>
);
};

View file

@ -16,6 +16,7 @@ export function Empty({ message }: EmptyProps) {
width="100%"
height="100%"
minHeight="70px"
flexGrow={1}
>
{message || formatMessage(messages.noDataAvailable)}
</Row>

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Heading, Icon, Row, Text } from '@umami/react-zen';
import { Heading, Icon, Row, RowProps, Text } from '@umami/react-zen';
export function PageHeader({
title,
@ -7,6 +7,7 @@ export function PageHeader({
icon,
showBorder = true,
children,
...props
}: {
title: string;
description?: string;
@ -15,7 +16,7 @@ export function PageHeader({
allowEdit?: boolean;
className?: string;
children?: ReactNode;
}) {
} & RowProps) {
return (
<Row
justifyContent="space-between"
@ -23,6 +24,7 @@ export function PageHeader({
paddingY="6"
border={showBorder ? 'bottom' : undefined}
width="100%"
{...props}
>
<Row alignItems="center" gap="3">
{icon && <Icon>{icon}</Icon>}

View file

@ -45,6 +45,7 @@ export {
Upload,
User,
Users,
UserPlus,
X as Close,
} from 'lucide-react';
export * from '@/components/svg';

View file

@ -0,0 +1,14 @@
import { Button, Icon } from '@umami/react-zen';
import { PanelLeft } from '@/components/icons';
import { useGlobalState } from '@/components/hooks';
export function PanelButton() {
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
return (
<Button onPress={() => setIsCollapsed(!isCollapsed)} variant="quiet">
<Icon>
<PanelLeft />
</Icon>
</Button>
);
}

View file

@ -16,13 +16,7 @@ import {
import { useLoginQuery, useMessages, useUserTeamsQuery, useNavigation } from '@/components/hooks';
import { Chevron, User, Users } from '@/components/icons';
export function TeamsButton({
className,
showText = true,
}: {
className?: string;
showText?: boolean;
}) {
export function TeamsButton({ showText = true }: { showText?: boolean }) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { data } = useUserTeamsQuery(user.id);
@ -41,13 +35,17 @@ export function TeamsButton({
return (
<MenuTrigger>
<Button className={className} variant="quiet">
<Row alignItems="center" gap="3">
<Icon>{teamId ? <Users /> : <User />}</Icon>
{showText && <Text>{teamId ? team?.name : user.username}</Text>}
<Icon rotate={90} size="sm">
<Chevron />
</Icon>
<Button variant="quiet">
<Row alignItems="center" justifyContent="space-between" width="100%" gap>
<Row alignItems="center" gap>
<Icon>{teamId ? <Users /> : <User />}</Icon>
{showText && <Text truncate>{teamId ? team?.name : user.username}</Text>}
</Row>
{showText && (
<Icon rotate={90} size="sm">
<Chevron />
</Icon>
)}
</Row>
</Button>
<Popover placement="bottom start">

View file

@ -1,16 +1,17 @@
import { useState } from 'react';
import { Select, SelectProps, ListItem } from '@umami/react-zen';
import { useUserWebsitesQuery, useWebsiteQuery, useNavigation } from '@/components/hooks';
import { ButtonProps } from 'react-basics';
export function WebsiteSelect({
websiteId,
teamId,
variant,
buttonProps,
...props
}: {
websiteId?: string;
teamId?: string;
variant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero';
buttonProps?: ButtonProps;
} & SelectProps) {
const { router, renderUrl } = useNavigation();
const [search, setSearch] = useState('');
@ -32,7 +33,7 @@ export function WebsiteSelect({
items={data?.['data'] || []}
value={websiteId}
isLoading={isLoading}
buttonProps={{ variant }}
buttonProps={buttonProps}
allowSearch={true}
searchValue={search}
onSearch={handleSearch}

View file

@ -338,7 +338,7 @@ export const labels = defineMessages({
location: { id: 'label.location', defaultMessage: 'Location' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
core: { id: 'label.core', defaultMessage: 'Core' },
traffic: { id: 'label.traffic', defaultMessage: 'Traffic' },
behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
growth: { id: 'label.growth', defaultMessage: 'Growth' },
});