Merge branch 'dev' of https://github.com/umami-software/umami into dev
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:
Francis Cao 2025-09-25 11:31:26 -07:00
commit 5bbc1a94b3
16 changed files with 116 additions and 107 deletions

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen';
import { Globe, LinkIcon, LogoSvg, Grid2x2, PanelLeft } from '@/components/icons';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { TeamsButton } from '@/components/input/TeamsButton';
import { NavButton } from '@/components/input/NavButton';
import { PanelButton } from '@/components/input/PanelButton';
import { Key } from 'react';
import { SettingsButton } from '@/components/input/SettingsButton';
@ -48,8 +48,8 @@ export function SideNav(props: SidebarProps) {
};
return (
<Row height="100%" backgroundColor border="right">
<Sidebar {...props} isCollapsed={isCollapsed || hasNav} muteItems={false} showBorder={false}>
<Row height="100%" backgroundColor>
<Sidebar {...props} isCollapsed={isCollapsed || hasNav}>
<SidebarSection onClick={() => setIsCollapsed(false)}>
<SidebarHeader
label="umami"
@ -60,7 +60,7 @@ export function SideNav(props: SidebarProps) {
</SidebarHeader>
</SidebarSection>
<SidebarSection paddingTop="0" paddingBottom="0" justifyContent="center">
<TeamsButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
<NavButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
</SidebarSection>
<SidebarSection flexGrow={1}>
{links.map(({ id, path, label, icon }) => {

View file

@ -53,7 +53,6 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
title={formatMessage(labels.settings)}
selectedKey={selectedKey}
allowMinimize={false}
muteItems={false}
/>
</Column>
<Column gap="6" margin="2">

View file

@ -182,7 +182,7 @@ export function WebsiteExpandedView({
return (
<Grid columns="auto 1fr" gap="6" height="100%" overflow="hidden">
<Column gap="6" border="right" paddingRight="3" overflowY="auto">
<SideMenu items={items} selectedKey={view} muteItems={false} />
<SideMenu items={items} selectedKey={view} />
</Column>
<Column overflow="hidden">
<MetricsExpandedTable

View file

@ -161,7 +161,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return (
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false} muteItems={false}>
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}>
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}

View file

@ -1,5 +1,6 @@
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { getAllUserTeams } from '@/queries';
export async function POST(request: Request) {
const { auth, error } = await parseRequest(request);
@ -8,5 +9,7 @@ export async function POST(request: Request) {
return error();
}
return json(auth.user);
const teams = await getAllUserTeams(auth.user.id);
return json({ ...auth.user, teams });
}

View file

@ -3,7 +3,7 @@ import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer() {
return (
<Row as="footer">
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>

View file

@ -1,20 +1,19 @@
import { Row, Icon, Text, ThemeButton } from '@umami/react-zen';
import Link from 'next/link';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { LogoSvg } from '@/components/icons';
export function Header() {
return (
<Row as="header">
<Row gap>
<Link href="https://umami.is" target="_blank">
<Icon size="lg">
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href="https://umami.is" target="_blank">
<Row alignItems="center" gap>
<Icon>
<LogoSvg />
</Icon>
<Text>umami</Text>
</Link>
</Row>
<Text weight="bold">umami</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />

View file

@ -1,4 +1,5 @@
'use client';
import { Column } from '@umami/react-zen';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { useShareTokenQuery } from '@/components/hooks';
@ -14,12 +15,14 @@ export function SharePage({ shareId }) {
}
return (
<PageBody>
<Header />
<WebsiteProvider websiteId={shareToken.websiteId}>
<WebsitePage websiteId={shareToken.websiteId} />
</WebsiteProvider>
<Footer />
</PageBody>
<Column backgroundColor="2">
<PageBody gap>
<Header />
<WebsiteProvider websiteId={shareToken.websiteId}>
<WebsitePage websiteId={shareToken.websiteId} />
</WebsiteProvider>
<Footer />
</PageBody>
</Column>
);
}

View file

@ -10,9 +10,8 @@ import {
Row,
Column,
Pressable,
Loading,
} from '@umami/react-zen';
import { useLoginQuery, useMessages, useUserTeamsQuery, useNavigation } from '@/components/hooks';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { ChevronRight, User, Users } from '@/components/icons';
export interface TeamsButtonProps {
@ -20,19 +19,14 @@ export interface TeamsButtonProps {
onAction?: (id: any) => void;
}
export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
export function NavButton({ showText = true, onAction }: TeamsButtonProps) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { data, isLoading } = useUserTeamsQuery(user.id);
const { teamId } = useNavigation();
const team = data?.data?.find(({ id }) => id === teamId);
const team = user?.teams?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || 'user']);
const label = teamId ? team?.name : user.username;
if (isLoading) {
return <Loading icon="dots" size="sm" placement="center" />;
}
return (
<MenuTrigger>
<Pressable>
@ -41,10 +35,13 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
justifyContent="space-between"
flexGrow={1}
padding
backgroundColor="2"
border
borderRadius
shadow="1"
maxHeight="40px"
style={{ cursor: 'pointer', textWrap: 'nowrap', outline: 'none' }}
>
<Row alignItems="center" gap>
<Row alignItems="center" position="relative" gap maxHeight="40px">
<Icon>{teamId ? <Users /> : <User />}</Icon>
{showText && <Text>{label}</Text>}
</Row>
@ -75,7 +72,7 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
</MenuSection>
<MenuSeparator />
<MenuSection title={formatMessage(labels.teams)}>
{data?.data?.map(({ id, name }) => (
{user?.teams?.map(({ id, name }) => (
<MenuItem key={id} id={id}>
<Row alignItems="center" gap>
<Icon size="sm">

View file

@ -3,6 +3,7 @@ import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetti
import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting';
import { Settings } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
export function PreferencesButton() {
const { formatMessage, labels } = useMessages();
@ -15,12 +16,16 @@ export function PreferencesButton() {
</Icon>
</Button>
<Popover placement="bottom end">
<Column gap="3">
<Label>{formatMessage(labels.timezone)}</Label>
<TimezoneSetting />
<Label>{formatMessage(labels.defaultDateRange)}</Label>
<DateRangeSetting />
</Column>
<Panel gap="3">
<Column>
<Label>{formatMessage(labels.timezone)}</Label>
<TimezoneSetting />
</Column>
<Column>
<Label>{formatMessage(labels.defaultDateRange)}</Label>
<DateRangeSetting />
</Column>
</Panel>
</Popover>
</DialogTrigger>
);

View file

@ -74,7 +74,7 @@ export * from '@/components/input/DateFilter';
export * from '@/components/input/DownloadButton';
export * from '@/components/input/ExportButton';
export * from '@/components/input/FilterButtons';
export * from '@/components/input/TeamsButton';
export * from '@/components/input/NavButton';
export * from '@/components/input/ProfileButton';
export * from '@/components/input/WebsiteSelect';

View file

@ -1,4 +1,5 @@
import path from 'path';
import path from 'node:path';
import { UAParser } from 'ua-parser-js';
import { browserName, detectOS } from 'detect-browser';
import isLocalhost from 'is-localhost-ip';
import ipaddr from 'ipaddr.js';
@ -7,35 +8,6 @@ import { safeDecodeURIComponent } from '@/lib/url';
const MAXMIND = 'maxmind';
export const DESKTOP_OS = [
'BeOS',
'Chrome OS',
'Linux',
'Mac OS',
'Open BSD',
'OS/2',
'QNX',
'Sun OS',
'Windows 10',
'Windows 2000',
'Windows 3.11',
'Windows 7',
'Windows 8',
'Windows 8.1',
'Windows 95',
'Windows 98',
'Windows ME',
'Windows Server 2003',
'Windows Vista',
'Windows XP',
];
export const MOBILE_OS = ['Amazon OS', 'Android OS', 'BlackBerry OS', 'iOS', 'Windows Mobile'];
export const DESKTOP_SCREEN_WIDTH = 1920;
export const LAPTOP_SCREEN_WIDTH = 1024;
export const MOBILE_SCREEN_WIDTH = 479;
// The order here is important and influences how IPs are detected by lib/detect.ts
// Please do not change the order unless you know exactly what you're doing - read https://developers.cloudflare.com/fundamentals/reference/http-headers/
export const IP_ADDRESS_HEADERS = [
@ -121,32 +93,10 @@ export function getIpAddress(headers: Headers) {
return ip;
}
export function getDevice(screen: string, os: string) {
if (!screen) return;
export function getDevice(userAgent: string) {
const { device } = UAParser(userAgent);
const [width] = screen.split('x');
if (DESKTOP_OS.includes(os)) {
if (os === 'Chrome OS' || +width < DESKTOP_SCREEN_WIDTH) {
return 'laptop';
}
return 'desktop';
} else if (MOBILE_OS.includes(os)) {
if (os === 'Amazon OS' || +width > MOBILE_SCREEN_WIDTH) {
return 'tablet';
}
return 'mobile';
}
if (+width >= DESKTOP_SCREEN_WIDTH) {
return 'desktop';
} else if (+width >= LAPTOP_SCREEN_WIDTH) {
return 'laptop';
} else if (+width >= MOBILE_SCREEN_WIDTH) {
return 'tablet';
} else {
return 'mobile';
}
return device?.type || 'desktop';
}
function getRegionCode(country: string, region: string) {
@ -221,7 +171,7 @@ export async function getClientInfo(request: Request, payload: Record<string, an
const city = safeDecodeURIComponent(location?.city);
const browser = browserName(userAgent);
const os = detectOS(userAgent) as string;
const device = getDevice(payload?.screen, os);
const device = getDevice(userAgent);
return { userAgent, browser, os, ip, country, region, city, device };
}

View file

@ -42,7 +42,7 @@ export async function getTeams(
);
}
export async function getUserTeams(userId: string, filters: QueryFilters) {
export async function getUserTeams(userId: string, filters: QueryFilters = {}) {
return getTeams(
{
where: {
@ -80,6 +80,22 @@ export async function getUserTeams(userId: string, filters: QueryFilters) {
);
}
export async function getAllUserTeams(userId: string) {
return prisma.client.team.findMany({
where: {
deletedAt: null,
members: {
some: { userId },
},
},
select: {
id: true,
name: true,
logoUrl: true,
},
});
}
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<any> {
const { id } = data;
const { client, transaction } = prisma;

View file

@ -6,7 +6,6 @@ body {
background-color: var(--background-color);
width: 100%;
min-height: 100vh;
overflow: hidden;
}
html[style*='padding-right'] {