Add UserButton to SideNav, refactor NavButton into TeamsButton

- Create UserButton component at bottom of SideNav with Settings, Language,
  Theme, Admin, and Logout menu items
- Move Settings/Logout/Admin/Docs/Support out of NavButton into UserButton
- Remove LanguageButton and ThemeButton from SideNav bottom
- Refactor NavButton into TeamsButton with simplified team switching
- Simplify WebsiteNav and move TeamsButton to App top bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-02-07 21:34:42 -08:00
parent f1b314e90b
commit 04c7216928
9 changed files with 382 additions and 186 deletions

View file

@ -5,6 +5,7 @@ import { useEffect } from 'react';
import { MobileNav } from '@/app/(main)/MobileNav';
import { SideNav } from '@/app/(main)/SideNav';
import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
import { TeamsButton } from '@/components/input/TeamsButton';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem, setItem } from '@/lib/storage';
import { UpdateNotice } from './UpdateNotice';
@ -50,6 +51,18 @@ export function App({ children }) {
<SideNav />
</Column>
<Column overflowX="hidden" position="relative">
<Row
position="sticky"
top="0"
alignItems="center"
justifyContent="space-between"
paddingY="2"
zIndex={100}
>
<Row backgroundColor="surface-raised">
<TeamsButton />
</Row>
</Row>
<Column alignItems="center">{children}</Column>
</Column>
<UpdateNotice user={user} config={config} />

View file

@ -5,7 +5,7 @@ import { IconLabel } from '@/components/common/IconLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { NavButton } from '@/components/input/NavButton';
import { TeamsButton } from '@/components/input/TeamsButton';
import { Logo } from '@/components/svg';
import { AdminNav } from './admin/AdminNav';
import { SettingsNav } from './settings/SettingsNav';
@ -44,7 +44,7 @@ export function MobileNav() {
return (
<>
<Row padding="3" onClick={close} border="bottom">
<NavButton />
<TeamsButton />
{links.map(link => {
return (
<Link key={link.id} href={renderUrl(link.path)}>

View file

@ -6,26 +6,21 @@ import {
Icon,
Row,
Text,
ThemeButton,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import Link from 'next/link';
import type { Key } from 'react';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { IconLabel } from '@/components/common/IconLabel';
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LayoutDashboard, LinkIcon, PanelLeft } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { NavButton } from '@/components/input/NavButton';
import { UserButton } from '@/components/input/UserButton';
import { Logo } from '@/components/svg';
export function SideNav(props: any) {
const { t, labels } = useMessages();
const { pathname, renderUrl, websiteId, router } = useNavigation();
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed', false);
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
const { pathname, renderUrl, websiteId } = useNavigation();
const [isCollapsed] = useGlobalState('sidenav-collapsed', false);
const links = [
{
@ -54,10 +49,6 @@ export function SideNav(props: any) {
},
];
const handleSelect = (id: Key) => {
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
};
return (
<Column
{...props}
@ -90,9 +81,6 @@ export function SideNav(props: any) {
<PanelButton />
</Row>
</Row>
<Row marginBottom="4" style={{ flexShrink: 0 }}>
<NavButton showText={!isCollapsed} onAction={handleSelect} />
</Row>
{websiteId ? (
<WebsiteNav websiteId={websiteId} isCollapsed={isCollapsed} />
) : (
@ -126,9 +114,8 @@ export function SideNav(props: any) {
</Column>
)}
</Column>
<Row alignItems="center" justifyContent="center" wrap="wrap" marginBottom="4" gap>
<LanguageButton />
<ThemeButton />
<Row marginBottom="4">
<UserButton showText={!isCollapsed} />
</Row>
</Column>
);

View file

@ -32,97 +32,66 @@ export function WebsiteNav({
router.push(renderUrl(`/websites/${value}`));
};
const renderValue = (value: any) => {
return (
<Text truncate style={{ maxWidth: 160, lineHeight: 1 }}>
{value?.selectedItem?.name}
</Text>
);
};
if (isCollapsed !== undefined) {
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 ? '' : t(labels.back)} padding />
</Row>
</Focusable>
<Tooltip placement="right">{t(labels.back)}</Tooltip>
</TooltipTrigger>
</Link>
{!isCollapsed && (
<Box marginBottom="2">
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleChange}
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={path} role="button">
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
backgroundColor={isSelected ? 'surface-sunken' : undefined}
borderRadius
minHeight="40px"
>
<IconLabel
icon={icon}
label={isCollapsed ? '' : label}
weight={isSelected ? 'bold' : undefined}
padding
/>
</Row>
</Focusable>
<Tooltip placement="right">{label}</Tooltip>
</TooltipTrigger>
</Link>
);
})}
</Column>
))}
</Column>
);
}
return (
<Column padding="2" position="sticky" top="0" gap>
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleChange}
renderValue={renderValue}
buttonProps={{ style: { outline: 'none' } }}
/>
<NavMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
<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 ? '' : t(labels.back)} padding />
</Row>
</Focusable>
<Tooltip placement="right">{t(labels.back)}</Tooltip>
</TooltipTrigger>
</Link>
<Box marginBottom="2">
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleChange}
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={path} role="button">
<TooltipTrigger isDisabled={!isCollapsed} delay={0}>
<Focusable>
<Row
alignItems="center"
hover={{ backgroundColor: 'surface-sunken' }}
backgroundColor={isSelected ? 'surface-sunken' : undefined}
borderRadius
minHeight="40px"
>
<IconLabel
icon={icon}
label={isCollapsed ? '' : label}
weight={isSelected ? 'bold' : undefined}
padding
/>
</Row>
</Focusable>
<Tooltip placement="right">{label}</Tooltip>
</TooltipTrigger>
</Link>
);
})}
</Column>
))}
</Column>
);
}

View file

@ -15,26 +15,10 @@ import {
import { ArrowRight } from 'lucide-react';
import type { Key } from 'react';
import { IconLabel } from '@/components/common/IconLabel';
import {
useConfig,
useLoginQuery,
useMessages,
useMobile,
useNavigation,
} from '@/components/hooks';
import {
BookText,
ChevronRight,
ExternalLink,
LifeBuoy,
LockKeyhole,
LogOut,
Settings,
User,
Users,
} from '@/components/icons';
import { useLoginQuery, useMessages, useMobile, useNavigation } from '@/components/hooks';
import { ChevronRight, User, Users } from '@/components/icons';
import { Switch } from '@/components/svg';
import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem } from '@/lib/storage';
export interface TeamsButtonProps {
@ -44,13 +28,13 @@ export interface TeamsButtonProps {
export function NavButton({ showText = true }: TeamsButtonProps) {
const { user } = useLoginQuery();
const { cloudMode } = useConfig();
const { t, labels } = useMessages();
const { teamId, router } = useNavigation();
const { isMobile } = useMobile();
const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username;
const cloudMode = !!process.env.cloudMode;
const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
@ -134,52 +118,6 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
</Column>
</Popover>
</SubmenuTrigger>
<MenuSeparator />
<MenuItem
id="settings"
href={getUrl('/settings')}
icon={<Settings />}
label={t(labels.settings)}
/>
{cloudMode && (
<>
<MenuItem
id="docs"
href={DOCS_URL}
target="_blank"
icon={<BookText />}
label={t(labels.documentation)}
>
<Icon color="muted">
<ExternalLink />
</Icon>
</MenuItem>
<MenuItem
id="support"
href={getUrl('/settings/support')}
icon={<LifeBuoy />}
label={t(labels.support)}
/>
</>
)}
{!cloudMode && user.isAdmin && (
<>
<MenuSeparator />
<MenuItem
id="/admin"
href="/admin"
icon={<LockKeyhole />}
label={t(labels.admin)}
/>
</>
)}
<MenuSeparator />
<MenuItem
id="logout"
href={getUrl('/logout')}
icon={<LogOut />}
label={t(labels.logout)}
/>
</Menu>
</Column>
</Popover>

View file

@ -0,0 +1,92 @@
import {
Button,
Column,
Icon,
Menu,
MenuItem,
MenuSection,
MenuSeparator,
MenuTrigger,
Popover,
Row,
Text,
} from '@umami/react-zen';
import { ArrowRight } from 'lucide-react';
import type { Key } from 'react';
import { IconLabel } from '@/components/common/IconLabel';
import { useLoginQuery, useMessages, useMobile, useNavigation } from '@/components/hooks';
import { ChevronRight, User, Users } from '@/components/icons';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem } from '@/lib/storage';
export function TeamsButton() {
const { user } = useLoginQuery();
const { t, labels } = useMessages();
const { teamId, router } = useNavigation();
const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username;
const cloudMode = !!process.env.cloudMode;
const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
};
const handleAction = async (key: Key) => {
if (key === 'user') {
removeItem(LAST_TEAM_CONFIG);
if (cloudMode) {
window.location.href = '/';
} else {
router.push('/');
}
}
};
return (
<MenuTrigger>
<Button variant="quiet">
<Row alignItems="center" position="relative" gap maxHeight="40px">
<Icon>{teamId ? <Users /> : <User />}</Icon>
<Text>{label}</Text>
</Row>
<Icon rotate={90} size="sm">
<ChevronRight />
</Icon>
</Button>
<Popover placement="bottom start">
<Column minWidth="300px">
<Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}>
<MenuSection title={t(labels.myAccount)}>
<MenuItem id="user">
<IconLabel icon={<User />} label={user.username} />
</MenuItem>
</MenuSection>
<MenuSeparator />
<MenuSection title={t(labels.teams)}>
{user?.teams?.map(({ id, name }) => (
<MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}>
<IconLabel icon={<Users />}>
<Text wrap="nowrap">{name}</Text>
</IconLabel>
</MenuItem>
))}
<MenuSeparator />
<MenuItem id="manage-teams">
<a href="/settings/teams" style={{ width: '100%' }}>
<Row alignItems="center" justifyContent="space-between" gap>
<Text align="center">Manage teams</Text>
<Icon>
<ArrowRight />
</Icon>
</Row>
</a>
</MenuItem>
</MenuSection>
</Menu>
</Column>
</Popover>
</MenuTrigger>
);
}

View file

@ -0,0 +1,204 @@
import {
Column,
Icon,
Menu,
MenuItem,
MenuSeparator,
MenuTrigger,
Popover,
Pressable,
Row,
SubmenuTrigger,
Text,
Tooltip,
TooltipTrigger,
useTheme,
} from '@umami/react-zen';
import { useConfig, useLocale, useLoginQuery, useMessages, useMobile } from '@/components/hooks';
import {
BookText,
ExternalLink,
Globe,
LifeBuoy,
LockKeyhole,
LogOut,
Moon,
Settings,
Sun,
SunMoon,
UserCircle,
} from '@/components/icons';
import { DOCS_URL } from '@/lib/constants';
import { languages } from '@/lib/lang';
export interface UserButtonProps {
showText?: boolean;
}
export function UserButton({ showText = true }: UserButtonProps) {
const { user } = useLoginQuery();
const { cloudMode } = useConfig();
const { t, labels } = useMessages();
const { locale, saveLocale } = useLocale();
const { theme, setTheme } = useTheme();
const { isMobile } = useMobile();
const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
};
const languageItems = Object.keys(languages).map(key => ({
value: key,
label: languages[key].label,
}));
const items = [
cloudMode && {
id: 'docs',
label: t(labels.documentation),
path: DOCS_URL,
icon: <BookText />,
target: '_blank',
external: true,
},
cloudMode && {
id: 'support',
label: t(labels.support),
path: getUrl('/settings/support'),
icon: <LifeBuoy />,
},
!cloudMode &&
user.isAdmin && {
id: 'admin',
label: t(labels.admin),
path: '/admin',
icon: <LockKeyhole />,
},
{
id: 'separator',
separator: true,
},
{
id: 'logout',
label: t(labels.logout),
path: getUrl('/logout'),
icon: <LogOut />,
},
].filter(Boolean);
return (
<MenuTrigger>
<TooltipTrigger isDisabled={showText} delay={0}>
<Pressable>
<Row
alignItems="center"
flexGrow={1}
hover={{ backgroundColor: 'surface-sunken' }}
borderRadius
minHeight="40px"
role="button"
style={{ cursor: 'pointer', textWrap: 'nowrap', overflow: 'hidden', outline: 'none' }}
>
<Row alignItems="center" gap padding>
<Icon>
<UserCircle />
</Icon>
{showText && <Text>{user.username}</Text>}
</Row>
</Row>
</Pressable>
<Tooltip placement="right">{user.username}</Tooltip>
</TooltipTrigger>
<Popover placement="top start">
<Column minWidth="200px">
<Menu autoFocus="last">
<MenuItem id="settings" href={getUrl('/settings')}>
<Row alignItems="center" gap>
<Icon>
<Settings />
</Icon>
<Text>{t(labels.settings)}</Text>
</Row>
</MenuItem>
<SubmenuTrigger>
<MenuItem id="language" showSubMenuIcon>
<Row alignItems="center" gap>
<Icon>
<Globe />
</Icon>
<Text>{t(labels.language)}</Text>
</Row>
</MenuItem>
<Popover placement={isMobile ? 'bottom start' : 'right bottom'} isNonModal>
<Menu
selectionMode="single"
selectedKeys={new Set([locale])}
onAction={key => saveLocale(key as string)}
style={{ maxHeight: 300, overflow: 'auto' }}
>
{languageItems.map(({ value, label }) => (
<MenuItem key={value} id={value}>
<Text weight={value === locale ? 'bold' : undefined}>{label}</Text>
</MenuItem>
))}
</Menu>
</Popover>
</SubmenuTrigger>
<SubmenuTrigger>
<MenuItem id="theme" showSubMenuIcon>
<Row alignItems="center" gap>
<Icon>
<SunMoon />
</Icon>
<Text>{t(labels.theme)}</Text>
</Row>
</MenuItem>
<Popover placement={isMobile ? 'bottom start' : 'right bottom'} isNonModal>
<Menu
selectionMode="single"
selectedKeys={new Set([theme])}
onAction={key => setTheme(key as 'light' | 'dark')}
>
<MenuItem id="light">
<Row alignItems="center" gap>
<Icon>
<Sun />
</Icon>
<Text>Light</Text>
</Row>
</MenuItem>
<MenuItem id="dark">
<Row alignItems="center" gap>
<Icon>
<Moon />
</Icon>
<Text>Dark</Text>
</Row>
</MenuItem>
</Menu>
</Popover>
</SubmenuTrigger>
{items.map(({ id, path, label, icon, separator, target, external }: any) => {
if (separator) {
return <MenuSeparator key={id} />;
}
return (
<MenuItem key={id} id={id} href={path} target={target}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>
<Text>{label}</Text>
{external && (
<Icon color="muted" size="sm">
<ExternalLink />
</Icon>
)}
</Row>
</MenuItem>
);
})}
</Menu>
</Column>
</Popover>
</MenuTrigger>
);
}

View file

@ -13,11 +13,13 @@ export function WebsiteSelect({
teamId,
onChange,
includeTeams,
isCollapsed,
...props
}: {
websiteId?: string;
teamId?: string;
includeTeams?: boolean;
isCollapsed?: boolean;
} & SelectProps) {
const { t, messages } = useMessages();
const { data: website } = useWebsiteQuery(websiteId);
@ -43,14 +45,6 @@ export function WebsiteSelect({
onChange(id);
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{name ?? website?.name}</Text>
</Row>
);
};
return (
<Select
{...props}
@ -61,7 +55,6 @@ export function WebsiteSelect({
onSearch={handleSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={t(messages.noResultsFound)} />,
style: { maxHeight: 'calc(42vh - 65px)', width: 280 },

View file

@ -74,8 +74,8 @@ export * from '@/components/input/DialogButton';
export * from '@/components/input/DownloadButton';
export * from '@/components/input/ExportButton';
export * from '@/components/input/FilterButtons';
export * from '@/components/input/NavButton';
export * from '@/components/input/ProfileButton';
export * from '@/components/input/TeamsButton';
export * from '@/components/input/WebsiteSelect';
export * from '@/components/metrics/ChangeLabel';
export * from '@/components/metrics/ListTable';