Compare commits

...

3 commits

Author SHA1 Message Date
Mike Cao
554054d3a1 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
2025-09-25 20:46:00 -07:00
Mike Cao
2f5e69229c Merge remote-tracking branch 'origin/dev' into dev 2025-09-25 15:58:34 -07:00
Mike Cao
805b3ec853 WIP: Nav merge 2025-09-25 15:58:25 -07:00
14 changed files with 243 additions and 57 deletions

View file

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

View file

@ -82,7 +82,7 @@
"@react-spring/web": "^10.0.1",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.85.5",
"@umami/react-zen": "^0.186.0",
"@umami/react-zen": "^0.187.0",
"@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2",
"chalk": "^5.6.0",

58
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.85.5
version: 5.85.5(react@19.1.1)
'@umami/react-zen':
specifier: ^0.186.0
version: 0.186.0(@babel/core@7.28.3)(@types/react@19.1.12)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
specifier: ^0.187.0
version: 0.187.0(@babel/core@7.28.3)(@types/react@19.1.12)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
'@umami/redis-client':
specifier: ^0.29.0
version: 0.29.0
@ -367,7 +367,44 @@ importers:
specifier: ^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:
@ -2738,8 +2775,8 @@ packages:
'@prisma/client': ^6.1.0
'@prisma/extension-read-replicas': ^0.4.1
'@umami/react-zen@0.186.0':
resolution: {integrity: sha512-s+x4cJK5UTHQ0l2TTUb3zX8P2U6bMw35NRjIqG+OJvljJf5NNdRo6WChZOvnh/08XxGI30jntFhUYdup255rFg==}
'@umami/react-zen@0.187.0':
resolution: {integrity: sha512-CiTGBqEvN/dcZ1Tq4R+mj9ynN1opZF81iukUzElChJ5XF/Ec9HhPR+KM2r8PXt+uWeVVe1aZtjyVOdwUR/ndXg==}
'@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -5246,6 +5283,11 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==}
peerDependencies:
@ -10343,7 +10385,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@umami/react-zen@0.186.0(@babel/core@7.28.3)(@types/react@19.1.12)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.1)(use-sync-external-store@1.5.0(react@19.1.1))':
'@umami/react-zen@0.187.0(@babel/core@7.28.3)(@types/react@19.1.12)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.1)(use-sync-external-store@1.5.0(react@19.1.1))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.9.0
@ -13376,6 +13418,10 @@ snapshots:
dependencies:
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):
dependencies:
react: 19.1.1

View file

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

View file

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

View file

@ -36,9 +36,21 @@ function MessagesProvider({ children }) {
export function Providers({ children }) {
const router = useRouter();
function navigate(url: string) {
if (shouldUseNativeLink(url)) {
window.location.href = url;
} else {
router.push(url);
}
}
function shouldUseNativeLink(url: string) {
return url.startsWith('http');
}
return (
<ZenProvider>
<RouterProvider navigate={router.push}>
<RouterProvider navigate={navigate}>
<MessagesProvider>
<QueryClientProvider client={client}>
<ErrorBoundary>{children}</ErrorBoundary>

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 {
Logo as LogoSvg,
Compare as CompareSvg,
Funnel as FunnelSvg,
Lightning as LightningSvg,
Location as LocationSvg,
Logo as LogoSvg,
Magnet as MagnetSvg,
Money as MoneySvg,
Network as NetworkSvg,
Path as PathSvg,
Switch as SwitchSvg,
Target as TargetSvg,
AddUser as AddUserSvg,
} from '@/components/svg';

View file

@ -6,27 +6,49 @@ import {
MenuTrigger,
MenuSection,
MenuSeparator,
SubmenuTrigger,
Popover,
Row,
Column,
Pressable,
IconLabel,
} from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { ChevronRight, User, Users } from '@/components/icons';
import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import {
BookText,
ChevronRight,
ExternalLink,
LifeBuoy,
LockKeyhole,
LogOut,
Settings,
User,
Users,
SwitchSvg,
} from '@/components/icons';
import { DOCS_URL } from '@/lib/constants';
import { ArrowRight } from 'lucide-react';
export interface TeamsButtonProps {
showText?: boolean;
onAction?: (id: any) => void;
}
export function NavButton({ showText = true, onAction }: TeamsButtonProps) {
export function NavButton({ showText = true }: TeamsButtonProps) {
const { user } = useLoginQuery();
const { cloudMode } = useConfig();
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username;
const getUrl = (url: string) => {
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
};
const handleAction = async () => {};
return (
<MenuTrigger>
<Pressable>
@ -54,35 +76,91 @@ export function NavButton({ showText = true, onAction }: TeamsButtonProps) {
</Pressable>
<Popover placement="bottom start">
<Column minWidth="300px">
<Menu
selectionMode="single"
selectedKeys={selectedKeys}
autoFocus="last"
onAction={onAction}
>
<MenuSection title={formatMessage(labels.myAccount)}>
<MenuItem id={'user'}>
<Row alignItems="center" gap>
<Icon>
<User />
</Icon>
<Text wrap="nowrap">{user.username}</Text>
</Row>
<Menu autoFocus="last" onAction={handleAction}>
<SubmenuTrigger>
<MenuItem id="teams" showChecked={false} showSubMenuIcon>
<IconLabel icon={<SwitchSvg />} label={formatMessage(labels.switchAccount)} />
</MenuItem>
</MenuSection>
<Popover placement="right top">
<Column minWidth="300px">
<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)}>
{user?.teams?.map(({ id, name }) => (
<MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}>
<IconLabel icon={<Users />}>
<Text wrap="nowrap">{name}</Text>
</IconLabel>
</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>
</Menu>
</Column>
</Popover>
</SubmenuTrigger>
<MenuSeparator />
<MenuSection title={formatMessage(labels.teams)}>
{user?.teams?.map(({ id, name }) => (
<MenuItem key={id} id={id}>
<Row alignItems="center" gap>
<Icon size="sm">
<Users />
</Icon>
<Text wrap="nowrap">{name}</Text>
</Row>
<MenuItem
id="settings"
href={getUrl('/settings')}
icon={<Settings />}
label={formatMessage(labels.settings)}
/>
{cloudMode && (
<>
<MenuItem
id="docs"
href={DOCS_URL}
target="_blank"
icon={<BookText />}
label={formatMessage(labels.documentation)}
>
<Icon color="muted">
<ExternalLink />
</Icon>
</MenuItem>
))}
</MenuSection>
<MenuItem
id="support"
href={getUrl('/settings/support')}
icon={<LifeBuoy />}
label={formatMessage(labels.support)}
/>
</>
)}
{!cloudMode && user.isAdmin && (
<>
<MenuSeparator />
<MenuItem
id="/admin"
href="/admin"
icon={<LockKeyhole />}
label={formatMessage(labels.admin)}
/>
</>
)}
<MenuSeparator />
<MenuItem
id="logout"
href={getUrl('/logout')}
icon={<LogOut />}
label={formatMessage(labels.logout)}
/>
</Menu>
</Column>
</Popover>

View file

@ -362,6 +362,7 @@ export const labels = defineMessages({
share: { id: 'label.share', defaultMessage: 'Share' },
support: { id: 'label.support', defaultMessage: 'Support' },
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
});
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 Compare } from './Compare';
export { default as Dashboard } from './Dashboard';
export { default as Download } from './Download';
export { default as Expand } from './Expand';
export { default as Export } from './Export';
export { default as Flag } from './Flag';
export { default as Funnel } from './Funnel';
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 Security } from './Security';
export { default as Speaker } from './Speaker';
export { default as Switch } from './Switch';
export { default as Tag } from './Tag';
export { default as Target } from './Target';
export { default as Visitor } from './Visitor';