Merged nav menus.
Some checks are pending
Create docker images / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run

This commit is contained in:
Mike Cao 2025-09-25 20:46:00 -07:00
parent 2f5e69229c
commit 554054d3a1
12 changed files with 164 additions and 47 deletions

View file

@ -3,15 +3,16 @@ import pkg from './package.json' assert { type: 'json' };
const TRACKER_SCRIPT = '/script.js'; const TRACKER_SCRIPT = '/script.js';
const basePath = process.env.BASE_PATH; const basePath = process.env.BASE_PATH || '';
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT; const cloudMode = process.env.CLOUD_MODE || '';
const cloudMode = !!process.env.CLOUD_MODE; const cloudUrl = process.env.CLOUD_URL || '';
const corsMaxAge = process.env.CORS_MAX_AGE; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
const defaultLocale = process.env.DEFAULT_LOCALE; const corsMaxAge = process.env.CORS_MAX_AGE || '';
const forceSSL = process.env.FORCE_SSL; const defaultLocale = process.env.DEFAULT_LOCALE || '';
const frameAncestors = process.env.ALLOWED_FRAME_URLS ?? ''; const forceSSL = process.env.FORCE_SSL || '';
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME; const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL; const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || '';
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || '';
const contentSecurityPolicy = [ const contentSecurityPolicy = [
`default-src 'self'`, `default-src 'self'`,
@ -163,6 +164,7 @@ export default {
env: { env: {
basePath, basePath,
cloudMode, cloudMode,
cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale, defaultLocale,
}, },

48
pnpm-lock.yaml generated
View file

@ -367,7 +367,44 @@ importers:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.2 version: 5.9.2
dist: {} dist:
dependencies:
chart.js:
specifier: ^4.5.0
version: 4.5.0
chartjs-adapter-date-fns:
specifier: ^3.0.0
version: 3.0.0(chart.js@4.5.0)(date-fns@2.30.0)
colord:
specifier: ^2.9.2
version: 2.9.3
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.1.1)
pure-rand:
specifier: ^7.0.1
version: 7.0.1
react-simple-maps:
specifier: ^2.3.0
version: 2.3.0(prop-types@15.8.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-use-measure:
specifier: ^2.0.4
version: 2.1.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-window:
specifier: ^1.8.6
version: 1.8.11(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
serialize-error:
specifier: ^12.0.0
version: 12.0.0
thenby:
specifier: ^1.3.4
version: 1.3.4
uuid:
specifier: ^11.1.0
version: 11.1.0
packages: packages:
@ -5246,6 +5283,11 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-react@0.543.0: lucide-react@0.543.0:
resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==} resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==}
peerDependencies: peerDependencies:
@ -13376,6 +13418,10 @@ snapshots:
dependencies: dependencies:
react: 19.1.1 react: 19.1.1
lucide-react@0.542.0(react@19.1.1):
dependencies:
react: 19.1.1
lucide-react@0.543.0(react@19.1.1): lucide-react@0.543.0(react@19.1.1):
dependencies: dependencies:
react: 19.1.1 react: 19.1.1

View file

@ -1,22 +1,25 @@
'use client'; 'use client';
import { Grid, Loading, Column } from '@umami/react-zen'; import { Grid, Loading, Column } from '@umami/react-zen';
import Script from 'next/script'; import Script from 'next/script';
import { usePathname } from 'next/navigation';
import { UpdateNotice } from './UpdateNotice'; import { UpdateNotice } from './UpdateNotice';
import { SideNav } from '@/app/(main)/SideNav'; import { SideNav } from '@/app/(main)/SideNav';
import { useLoginQuery, useConfig } from '@/components/hooks'; import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
export function App({ children }) { export function App({ children }) {
const { user, isLoading, error } = useLoginQuery(); const { user, isLoading, error } = useLoginQuery();
const config = useConfig(); const config = useConfig();
const pathname = usePathname(); const { pathname, router } = useNavigation();
if (isLoading || !config) { if (isLoading || !config) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
if (error) { if (error) {
window.location.href = `${process.env.basePath || ''}/login`; if (process.env.cloudMode) {
window.location.href = '/login';
} else {
router.push('/login');
}
return null; return null;
} }

View file

@ -1,3 +1,4 @@
import { Key } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
Sidebar, Sidebar,
@ -12,8 +13,7 @@ import { Globe, LinkIcon, LogoSvg, Grid2x2, PanelLeft } from '@/components/icons
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks'; import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { NavButton } from '@/components/input/NavButton'; import { NavButton } from '@/components/input/NavButton';
import { PanelButton } from '@/components/input/PanelButton'; import { PanelButton } from '@/components/input/PanelButton';
import { Key } from 'react'; import { LanguageButton } from '@/components/input/LanguageButton';
import { SettingsButton } from '@/components/input/SettingsButton';
export function SideNav(props: SidebarProps) { export function SideNav(props: SidebarProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -77,9 +77,9 @@ export function SideNav(props: SidebarProps) {
})} })}
</SidebarSection> </SidebarSection>
<SidebarSection justifyContent="flex-start"> <SidebarSection justifyContent="flex-start">
<Row> <Row wrap="wrap">
<SettingsButton /> <LanguageButton />
{!isCollapsed && !hasNav && <ThemeButton />} <ThemeButton />
</Row> </Row>
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>

1
src/assets/switch.svg Normal file
View file

@ -0,0 +1 @@
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg"><path d="M16 3l4 4l-4 4"></path><path d="M10 7l10 0"></path><path d="M8 13l-4 4l4 4"></path><path d="M4 17l9 0"></path></svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -1,14 +1,14 @@
export * from 'lucide-react'; export * from 'lucide-react';
export { export {
Logo as LogoSvg,
Compare as CompareSvg, Compare as CompareSvg,
Funnel as FunnelSvg, Funnel as FunnelSvg,
Lightning as LightningSvg, Lightning as LightningSvg,
Location as LocationSvg, Location as LocationSvg,
Logo as LogoSvg,
Magnet as MagnetSvg, Magnet as MagnetSvg,
Money as MoneySvg, Money as MoneySvg,
Network as NetworkSvg, Network as NetworkSvg,
Path as PathSvg, Path as PathSvg,
Switch as SwitchSvg,
Target as TargetSvg, Target as TargetSvg,
AddUser as AddUserSvg,
} from '@/components/svg'; } from '@/components/svg';

View file

@ -24,9 +24,10 @@ import {
Settings, Settings,
User, User,
Users, Users,
SwitchSvg,
} from '@/components/icons'; } from '@/components/icons';
import { DOCS_URL } from '@/lib/constants'; import { DOCS_URL } from '@/lib/constants';
import * as url from 'node:url'; import { ArrowRight } from 'lucide-react';
export interface TeamsButtonProps { export interface TeamsButtonProps {
showText?: boolean; showText?: boolean;
@ -43,7 +44,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
const label = teamId ? team?.name : user.username; const label = teamId ? team?.name : user.username;
const getUrl = (url: string) => { const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}/${url}` : url; return cloudMode ? `${process.env.cloudUrl}${url}` : url;
}; };
const handleAction = async () => {}; const handleAction = async () => {};
@ -75,42 +76,52 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
</Pressable> </Pressable>
<Popover placement="bottom start"> <Popover placement="bottom start">
<Column minWidth="300px"> <Column minWidth="300px">
<Menu <Menu autoFocus="last" onAction={handleAction}>
selectionMode="single"
selectedKeys={selectedKeys}
autoFocus="last"
onAction={handleAction}
>
<MenuSection title={formatMessage(labels.myAccount)}>
<MenuItem id="user">
<IconLabel icon={<User />} label={user.username} />
</MenuItem>
</MenuSection>
<MenuSeparator />
<SubmenuTrigger> <SubmenuTrigger>
<MenuItem id="teams" showChecked={false} showSubMenuIcon> <MenuItem id="teams" showChecked={false} showSubMenuIcon>
<IconLabel icon={<Users />} label={formatMessage(labels.teams)} /> <IconLabel icon={<SwitchSvg />} label={formatMessage(labels.switchAccount)} />
</MenuItem> </MenuItem>
<Popover placement="right top"> <Popover placement="right top">
<Column minWidth="300px"> <Column minWidth="300px">
<Menu> <Menu selectionMode="single" selectedKeys={selectedKeys}>
<MenuSection title={formatMessage(labels.myAccount)}>
<MenuItem id="user" href={getUrl('/')}>
<IconLabel icon={<User />} label={user.username} />
</MenuItem>
</MenuSection>
<MenuSeparator />
<MenuSection title={formatMessage(labels.teams)}> <MenuSection title={formatMessage(labels.teams)}>
{user?.teams?.map(({ id, name }) => ( {user?.teams?.map(({ id, name }) => (
<MenuItem key={id} id={id}> <MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}>
<Row alignItems="center" gap> <IconLabel icon={<Users />}>
<Icon size="sm">
<Users />
</Icon>
<Text wrap="nowrap">{name}</Text> <Text wrap="nowrap">{name}</Text>
</Row> </IconLabel>
</MenuItem> </MenuItem>
))} ))}
{user?.teams?.length === 0 && (
<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> </MenuSection>
</Menu> </Menu>
</Column> </Column>
</Popover> </Popover>
</SubmenuTrigger> </SubmenuTrigger>
<MenuItem id="settings" icon={<Settings />} label={formatMessage(labels.settings)} /> <MenuSeparator />
<MenuItem
id="settings"
href={getUrl('/settings')}
icon={<Settings />}
label={formatMessage(labels.settings)}
/>
{cloudMode && ( {cloudMode && (
<> <>
<MenuItem <MenuItem
@ -132,14 +143,24 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
/> />
</> </>
)} )}
{user.isAdmin && ( {!cloudMode && user.isAdmin && (
<> <>
<MenuSeparator /> <MenuSeparator />
<MenuItem id="/admin" icon={<LockKeyhole />} label={formatMessage(labels.admin)} /> <MenuItem
id="/admin"
href="/admin"
icon={<LockKeyhole />}
label={formatMessage(labels.admin)}
/>
</> </>
)} )}
<MenuSeparator /> <MenuSeparator />
<MenuItem id="/logout" icon={<LogOut />} label={formatMessage(labels.logout)} /> <MenuItem
id="logout"
href={getUrl('/logout')}
icon={<LogOut />}
label={formatMessage(labels.logout)}
/>
</Menu> </Menu>
</Column> </Column>
</Popover> </Popover>

View file

@ -362,6 +362,7 @@ export const labels = defineMessages({
share: { id: 'label.share', defaultMessage: 'Share' }, share: { id: 'label.share', defaultMessage: 'Share' },
support: { id: 'label.support', defaultMessage: 'Support' }, support: { id: 'label.support', defaultMessage: 'Support' },
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' }, documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -0,0 +1,9 @@
import * as React from 'react';
import type { SVGProps } from 'react';
const SvgDownload = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
<path d="M97.5 82.656V71.357a3.545 3.545 0 0 0-3.545-3.544H89.17a3.545 3.545 0 0 0-3.545 3.544v11.3c0 1.639-1.33 2.968-2.969 2.968H17.344a2.97 2.97 0 0 1-2.969-2.969V71.357a3.545 3.545 0 0 0-3.545-3.545H6.045A3.545 3.545 0 0 0 2.5 71.357v11.3C2.5 90.853 9.146 97.5 17.344 97.5h65.312c8.198 0 14.844-6.646 14.844-14.844" />
<path d="m29.68 44.105-3.387 3.388a3.545 3.545 0 0 0 0 5.014l19.506 19.506a5.94 5.94 0 0 0 8.397.005l.005-.005 19.506-19.506a3.545 3.545 0 0 0 0-5.014l-3.388-3.388a3.545 3.545 0 0 0-5.013 0l-9.368 9.368V6.045A3.545 3.545 0 0 0 52.393 2.5h-4.786a3.545 3.545 0 0 0-3.544 3.545v47.428l-9.369-9.368a3.545 3.545 0 0 0-5.013 0" />
</svg>
);
export default SvgDownload;

View file

@ -0,0 +1,12 @@
import * as React from 'react';
import type { SVGProps } from 'react';
const SvgExport = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
<switch>
<g>
<path d="M8.7 7.7 11 5.4V15c0 .6.4 1 1 1s1-.4 1-1V5.4l2.3 2.3c.4.4 1 .4 1.4 0s.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0M21 14c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1" />
</g>
</switch>
</svg>
);
export default SvgExport;

View file

@ -0,0 +1,19 @@
import * as React from 'react';
import type { SVGProps } from 'react';
const SvgSwitch = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={200}
height={200}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}
>
<path d="m16 3 4 4-4 4M10 7h10M8 13l-4 4 4 4M4 17h9" />
</svg>
);
export default SvgSwitch;

View file

@ -6,7 +6,9 @@ export { default as Bookmark } from './Bookmark';
export { default as Change } from './Change'; export { default as Change } from './Change';
export { default as Compare } from './Compare'; export { default as Compare } from './Compare';
export { default as Dashboard } from './Dashboard'; export { default as Dashboard } from './Dashboard';
export { default as Download } from './Download';
export { default as Expand } from './Expand'; export { default as Expand } from './Expand';
export { default as Export } from './Export';
export { default as Flag } from './Flag'; export { default as Flag } from './Flag';
export { default as Funnel } from './Funnel'; export { default as Funnel } from './Funnel';
export { default as Gear } from './Gear'; export { default as Gear } from './Gear';
@ -28,6 +30,7 @@ export { default as Redo } from './Redo';
export { default as Reports } from './Reports'; export { default as Reports } from './Reports';
export { default as Security } from './Security'; export { default as Security } from './Security';
export { default as Speaker } from './Speaker'; export { default as Speaker } from './Speaker';
export { default as Switch } from './Switch';
export { default as Tag } from './Tag'; export { default as Tag } from './Tag';
export { default as Target } from './Target'; export { default as Target } from './Target';
export { default as Visitor } from './Visitor'; export { default as Visitor } from './Visitor';