Combine WebsiteNav into SideNav when viewing a website.

Replace the separate 240px WebsiteNav sidebar with website navigation
integrated into the main SideNav. When a websiteId is active, SideNav
shows a back arrow, WebsiteSelect dropdown, and grouped nav items
instead of the main links. Extract shared useWebsiteNavItems hook used
by both SideNav and WebsiteNav (still used by MobileNav).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-02-06 03:57:28 -08:00
parent 84522b2475
commit 635317ecfb
5 changed files with 284 additions and 165 deletions

View file

@ -14,15 +14,23 @@ import {
import Link from 'next/link';
import type { Key } from 'react';
import { IconLabel } from '@/components/common/IconLabel';
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LayoutDashboard, LinkIcon, PanelLeft } from '@/components/icons';
import { useGlobalState, useMessages, useNavigation, useWebsiteNavItems } from '@/components/hooks';
import {
ArrowLeft,
Globe,
Grid2x2,
LayoutDashboard,
LinkIcon,
PanelLeft,
} from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { NavButton } from '@/components/input/NavButton';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import { Logo } from '@/components/svg';
export function SideNav(props: any) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl, websiteId, router } = useNavigation();
const { pathname, renderUrl, websiteId, teamId, router } = useNavigation();
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
@ -58,6 +66,10 @@ export function SideNav(props: any) {
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
};
const handleWebsiteChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`));
};
return (
<Column
{...props}
@ -73,8 +85,13 @@ export function SideNav(props: any) {
transition: 'width 0.2s ease-in-out',
}}
>
<Column>
<Row alignItems="center" justifyContent="space-between" height="60px">
<Column overflowY="auto" style={{ minHeight: 0 }}>
<Row
alignItems="center"
justifyContent="space-between"
height="60px"
style={{ flexShrink: 0 }}
>
<Row paddingX="3" alignItems="center" justifyContent="space-between" flexGrow={1}>
{!isCollapsed && (
<IconLabel icon={<Logo />}>
@ -84,18 +101,120 @@ export function SideNav(props: any) {
<PanelButton />
</Row>
</Row>
<Row marginBottom="4">
<Row marginBottom="4" style={{ flexShrink: 0 }}>
<NavButton showText={!isCollapsed} onAction={handleSelect} />
</Row>
<Column gap="2">
{links.map(({ id, path, label, icon }) => {
{websiteId ? (
<WebsiteNavSection
websiteId={websiteId}
teamId={teamId}
isCollapsed={isCollapsed}
onWebsiteChange={handleWebsiteChange}
/>
) : (
<Column gap="2">
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
borderRadius
minHeight="40px"
>
<IconLabel icon={icon} label={isCollapsed ? '' : label} padding />
</Row>
</Focusable>
<Tooltip placement="right">{label}</Tooltip>
</TooltipTrigger>
</Link>
);
})}
</Column>
)}
</Column>
<Row alignItems="center" justifyContent="center" wrap="wrap" marginBottom="4" gap>
<LanguageButton />
<ThemeButton />
</Row>
</Column>
);
}
function WebsiteNavSection({
websiteId,
teamId,
isCollapsed,
onWebsiteChange,
}: {
websiteId: string;
teamId: string;
isCollapsed: boolean;
onWebsiteChange: (value: string) => void;
}) {
const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation();
const { items, selectedKey } = useWebsiteNavItems(websiteId);
const renderValue = (value: any) => {
return (
<Text truncate style={{ maxWidth: 160, lineHeight: 1 }}>
{value?.selectedItem?.name}
</Text>
);
};
return (
<Column gap="2">
<Link href={renderUrl('/websites', false)} role="button">
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
borderRadius
minHeight="40px"
>
<IconLabel
icon={<ArrowLeft />}
label={isCollapsed ? '' : formatMessage(labels.back)}
padding
/>
</Row>
</Focusable>
<Tooltip placement="right">{formatMessage(labels.back)}</Tooltip>
</TooltipTrigger>
</Link>
{!isCollapsed && (
<Box marginBottom="2">
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={onWebsiteChange}
renderValue={renderValue}
buttonProps={{ style: { outline: 'none' } }}
/>
</Box>
)}
{items.map(({ label: sectionLabel, items: sectionItems }, index) => (
<Column key={`${sectionLabel}${index}`} gap="1" marginBottom="1">
{!isCollapsed && (
<Row padding>
<Text weight="bold">{sectionLabel}</Text>
</Row>
)}
{sectionItems.map(({ id, path, label, icon }) => {
const isSelected = selectedKey === id;
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<Link key={id} href={path} role="button">
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
backgroundColor={isSelected ? 'surface-sunken' : undefined}
borderRadius
minHeight="40px"
>
@ -108,11 +227,7 @@ export function SideNav(props: any) {
);
})}
</Column>
</Column>
<Row alignItems="center" justifyContent="center" wrap="wrap" marginBottom="4" gap>
<LanguageButton />
<ThemeButton />
</Row>
))}
</Column>
);
}

View file

@ -1,23 +1,17 @@
'use client';
import { Column, Grid } from '@umami/react-zen';
import { Column } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { WebsiteHeader } from './WebsiteHeader';
import { WebsiteNav } from './WebsiteNav';
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return (
<WebsiteProvider websiteId={websiteId}>
<Grid columns={{ base: '1fr', lg: 'auto 1fr' }} width="100%">
<Column display={{ base: 'none', lg: 'flex' }} width="240px" border="right" marginRight="2">
<WebsiteNav websiteId={websiteId} />
</Column>
<PageBody gap>
<WebsiteHeader showActions />
<Column>{children}</Column>
</PageBody>
</Grid>
<PageBody gap>
<WebsiteHeader showActions />
<Column>{children}</Column>
</PageBody>
</WebsiteProvider>
);
}

View file

@ -1,18 +1,7 @@
import { Column, Text } from '@umami/react-zen';
import { NavMenu } from '@/components/common/NavMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import {
AlignEndHorizontal,
ChartPie,
Clock,
Eye,
Sheet,
Tag,
User,
UserPlus,
} from '@/components/icons';
import { useNavigation, useWebsiteNavItems } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function WebsiteNav({
websiteId,
@ -21,130 +10,8 @@ export function WebsiteNav({
websiteId: string;
onItemClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl, teamId, router } = useNavigation();
const renderPath = (path: string) =>
renderUrl(`/websites/${websiteId}${path}`, {
event: undefined,
compare: undefined,
view: undefined,
unit: undefined,
excludeBounce: undefined,
});
const items = [
{
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: renderPath(''),
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: renderPath('/events'),
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: renderPath('/sessions'),
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <AlignEndHorizontal />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: renderPath('/breakdown'),
},
],
},
{
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: renderPath('/goals'),
},
{
id: 'funnel',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: renderPath('/funnels'),
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: renderPath('/journeys'),
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: renderPath('/retention'),
},
],
},
{
label: formatMessage(labels.audience),
items: [
{
id: 'segments',
label: formatMessage(labels.segments),
icon: <ChartPie />,
path: renderPath('/segments'),
},
{
id: 'cohorts',
label: formatMessage(labels.cohorts),
icon: <UserPlus />,
path: renderPath('/cohorts'),
},
],
},
{
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: renderPath('/utm'),
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: renderPath('/revenue'),
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: renderPath('/attribution'),
},
],
},
];
const { teamId, router, renderUrl } = useNavigation();
const { items, selectedKey } = useWebsiteNavItems(websiteId);
const handleChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`));
@ -158,10 +25,6 @@ export function WebsiteNav({
);
};
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return (
<Column padding="2" position="sticky" top="0" gap>
<WebsiteSelect

View file

@ -87,3 +87,4 @@ export * from './useRegionNames';
export * from './useSlug';
export * from './useSticky';
export * from './useTimezone';
export * from './useWebsiteNavItems';

View file

@ -0,0 +1,146 @@
import {
AlignEndHorizontal,
ChartPie,
Clock,
Eye,
Sheet,
Tag,
User,
UserPlus,
} from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { useMessages } from './useMessages';
import { useNavigation } from './useNavigation';
export function useWebsiteNavItems(websiteId: string) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl } = useNavigation();
const renderPath = (path: string) =>
renderUrl(`/websites/${websiteId}${path}`, {
event: undefined,
compare: undefined,
view: undefined,
unit: undefined,
excludeBounce: undefined,
});
const items = [
{
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: renderPath(''),
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: renderPath('/events'),
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: renderPath('/sessions'),
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <AlignEndHorizontal />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: renderPath('/breakdown'),
},
],
},
{
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: renderPath('/goals'),
},
{
id: 'funnel',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: renderPath('/funnels'),
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: renderPath('/journeys'),
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: renderPath('/retention'),
},
],
},
{
label: formatMessage(labels.audience),
items: [
{
id: 'segments',
label: formatMessage(labels.segments),
icon: <ChartPie />,
path: renderPath('/segments'),
},
{
id: 'cohorts',
label: formatMessage(labels.cohorts),
icon: <UserPlus />,
path: renderPath('/cohorts'),
},
],
},
{
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: renderPath('/utm'),
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: renderPath('/revenue'),
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: renderPath('/attribution'),
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return { items, selectedKey, renderPath };
}