Updates for cloud mode.
Some checks failed
Node.js CI / build (postgresql, 18.18) (push) Has been cancelled

This commit is contained in:
Mike Cao 2025-09-04 20:27:42 -07:00
parent dc1736458b
commit f40e1b44f3
51 changed files with 251 additions and 173 deletions

View file

@ -1,15 +1,10 @@
import 'dotenv/config'; import 'dotenv/config';
import { createRequire } from 'module'; import pkg from './package.json' assert { type: 'json' };
const require = createRequire(import.meta.url);
const pkg = require('./package.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 collectApiEndpoint = process.env.COLLECT_API_ENDPOINT;
const cloudMode = process.env.CLOUD_MODE;
const cloudUrl = process.env.CLOUD_URL; const cloudUrl = process.env.CLOUD_URL;
const corsMaxAge = process.env.CORS_MAX_AGE; const corsMaxAge = process.env.CORS_MAX_AGE;
const defaultLocale = process.env.DEFAULT_LOCALE; const defaultLocale = process.env.DEFAULT_LOCALE;
@ -162,7 +157,7 @@ if (trackerScriptName) {
} }
} }
if (cloudMode && cloudUrl) { if (cloudUrl) {
redirects.push({ redirects.push({
source: '/login', source: '/login',
destination: cloudUrl, destination: cloudUrl,
@ -175,7 +170,6 @@ export default {
reactStrictMode: false, reactStrictMode: false,
env: { env: {
basePath, basePath,
cloudMode,
cloudUrl, cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale, defaultLocale,

View file

@ -1,6 +1,6 @@
{ {
"name": "@umami/components", "name": "@umami/components",
"version": "0.115.0", "version": "0.116.0",
"description": "Umami React components.", "description": "Umami React components.",
"author": "Mike Cao <mike@mikecao.com>", "author": "Mike Cao <mike@mikecao.com>",
"license": "MIT", "license": "MIT",

View file

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

10
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.85.5 specifier: ^5.85.5
version: 5.85.5(react@19.1.1) version: 5.85.5(react@19.1.1)
'@umami/react-zen': '@umami/react-zen':
specifier: ^0.176.0 specifier: ^0.179.0
version: 0.176.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)) version: 0.179.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': '@umami/redis-client':
specifier: ^0.29.0 specifier: ^0.29.0
version: 0.29.0 version: 0.29.0
@ -2735,8 +2735,8 @@ packages:
'@prisma/client': ^6.1.0 '@prisma/client': ^6.1.0
'@prisma/extension-read-replicas': ^0.4.1 '@prisma/extension-read-replicas': ^0.4.1
'@umami/react-zen@0.176.0': '@umami/react-zen@0.179.0':
resolution: {integrity: sha512-GP+Df68w0Kfo09ZC5WgK1YCg/IbqOz7HWPw8PLYG3shYm+feuF8ND8DHjoTle4qXY4oRJFufXCEsDbTb8FITkg==} resolution: {integrity: sha512-OS6Xa1tMO2PfXYXEB98XyOBctvnw/gE2eO3gK5mkJF5P3Ati6Z9KpTpj28Fxi7PgJiWYqcQ5enFzQqHXjYy/3A==}
'@umami/redis-client@0.29.0': '@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==} resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -10333,7 +10333,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@umami/react-zen@0.176.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.179.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: dependencies:
'@fontsource/jetbrains-mono': 5.2.6 '@fontsource/jetbrains-mono': 5.2.6
'@internationalized/date': 3.9.0 '@internationalized/date': 3.9.0

View file

@ -22,6 +22,13 @@ if (!process.env.SKIP_DB_CHECK && !process.env.DATABASE_TYPE) {
checkMissing(['DATABASE_URL']); checkMissing(['DATABASE_URL']);
} }
if (process.env.CLOUD_MODE) { if (process.env.CLOUD_URL) {
checkMissing(['CLOUD_URL', 'KAFKA_BROKER', 'KAFKA_URL', 'REDIS_URL', 'KAFKA_SASL_MECHANISM']); checkMissing([
'CLOUD_URL',
'CLICKHOUSE_URL',
'REDIS_URL',
'KAFKA_BROKER',
'KAFKA_URL',
'KAFKA_SASL_MECHANISM',
]);
} }

View file

@ -11,8 +11,8 @@ export function App({ children }) {
const config = useConfig(); const config = useConfig();
const pathname = usePathname(); const pathname = usePathname();
if (isLoading) { if (isLoading || !config) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (error) { if (error) {

View file

@ -14,15 +14,13 @@ import {
Link as LinkIcon, Link as LinkIcon,
Logo, Logo,
Pixel, Pixel,
Settings,
PanelLeft, PanelLeft,
} from '@/components/icons'; } from '@/components/icons';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks'; import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { TeamsButton } from '@/components/input/TeamsButton'; import { TeamsButton } from '@/components/input/TeamsButton';
import { PanelButton } from '@/components/input/PanelButton'; import { PanelButton } from '@/components/input/PanelButton';
import { ProfileButton } from '@/components/input/ProfileButton';
import { LanguageButton } from '@/components/input/LanguageButton';
import { Key } from 'react'; import { Key } from 'react';
import { SettingsButton } from '@/components/input/SettingsButton';
export function SideNav(props: SidebarProps) { export function SideNav(props: SidebarProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -58,17 +56,7 @@ export function SideNav(props: SidebarProps) {
}, },
]; ];
const bottomLinks = [
{
id: 'settings',
label: formatMessage(labels.settings),
path: renderUrl('/settings'),
icon: <Settings />,
},
];
const handleSelect = (id: Key) => { const handleSelect = (id: Key) => {
console.log({ id });
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`); router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
}; };
@ -78,18 +66,15 @@ export function SideNav(props: SidebarProps) {
<SidebarSection onClick={() => setIsCollapsed(false)}> <SidebarSection onClick={() => setIsCollapsed(false)}>
<SidebarHeader <SidebarHeader
label="umami" label="umami"
icon={ icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
isCollapsed && !hasNav ? (
<PanelLeft />
) : (
<Logo onClick={() => (window.location.href = process.env.cloudUrl)} />
)
}
style={{ maxHeight: 40 }} style={{ maxHeight: 40 }}
> >
{!isCollapsed && !hasNav && <PanelButton />} {!isCollapsed && !hasNav && <PanelButton />}
</SidebarHeader> </SidebarHeader>
</SidebarSection> </SidebarSection>
<SidebarSection paddingTop="0" paddingBottom="0" justifyContent="center">
<TeamsButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
</SidebarSection>
<SidebarSection flexGrow={1}> <SidebarSection flexGrow={1}>
{links.map(({ id, path, label, icon }) => { {links.map(({ id, path, label, icon }) => {
return ( return (
@ -104,30 +89,10 @@ export function SideNav(props: SidebarProps) {
); );
})} })}
</SidebarSection> </SidebarSection>
<SidebarSection style={{ paddingTop: 0, paddingBottom: 0 }}> <SidebarSection justifyContent="flex-start">
<TeamsButton showText={!hasNav && !isCollapsed} onAction={handleSelect} /> <Row>
</SidebarSection> <SettingsButton />
<SidebarSection> {!isCollapsed && !hasNav && <ThemeButton />}
{bottomLinks.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={path} role="button">
<SidebarItem
label={label}
icon={icon}
isSelected={pathname.includes(path)}
role="button"
/>
</Link>
);
})}
<Row alignItems="center" height="40px">
<ProfileButton />
{!isCollapsed && !hasNav && (
<Row>
<LanguageButton />
<ThemeButton />
</Row>
)}
</Row> </Row>
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>

View file

@ -18,7 +18,7 @@ export function UpdateNotice({ user, config }) {
!config?.updatesDisabled && !config?.updatesDisabled &&
!config?.privateMode && !config?.privateMode &&
!pathname.includes('/share/') && !pathname.includes('/share/') &&
!process.env.cloudMode && !process.env.cloudUrl &&
!dismissed; !dismissed;
const updateCheck = useCallback(() => { const updateCheck = useCallback(() => {

View file

@ -11,7 +11,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
if (!user.isAdmin) { if (!user.isAdmin || process.env.cloudUrl) {
return null; return null;
} }

View file

@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { AdminLayout } from './AdminLayout'; import { AdminLayout } from './AdminLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudMode) { if (process.env.cloudUrl) {
return null; return null;
} }

View file

@ -9,7 +9,7 @@ export function UserProvider({ userId, children }: { userId: string; children: R
const { data: user, isFetching, isLoading } = useUserQuery(userId); const { data: user, isFetching, isLoading } = useUserQuery(userId);
if (isFetching && isLoading) { if (isFetching && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (!user) { if (!user) {

View file

@ -78,7 +78,7 @@ export function LinkEditForm({
}, [data]); }, [data]);
if (linkId && isLoading) { if (linkId && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
return ( return (

View file

@ -10,7 +10,7 @@ export function LinkProvider({ linkId, children }: { linkId?: string; children:
const { data: link, isLoading, isFetching } = useLinkQuery(linkId); const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
if (isFetching && isLoading) { if (isFetching && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (!link) { if (!link) {

View file

@ -70,7 +70,7 @@ export function PixelEditForm({
}, [data]); }, [data]);
if (pixelId && isLoading) { if (pixelId && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
return ( return (

View file

@ -10,7 +10,7 @@ export function PixelProvider({ pixelId, children }: { pixelId?: string; childre
const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId); const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
if (isFetching && isLoading) { if (isFetching && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (!pixel) { if (!pixel) {

View file

@ -2,6 +2,10 @@ import { Metadata } from 'next';
import { SettingsLayout } from './SettingsLayout'; import { SettingsLayout } from './SettingsLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudUrl) {
return null;
}
return <SettingsLayout>{children}</SettingsLayout>; return <SettingsLayout>{children}</SettingsLayout>;
} }

View file

@ -15,7 +15,7 @@ export function DateRangeSetting() {
return ( return (
<Row gap="3"> <Row gap="3">
<DateFilter value={value} onChange={handleChange} /> <DateFilter value={value} onChange={handleChange} placement="bottom start" />
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row> </Row>
); );

View file

@ -26,7 +26,7 @@ export function LanguageSetting() {
}; };
return ( return (
<Row gap="3"> <Row gap>
<Select <Select
value={locale} value={locale}
onChange={val => saveLocale(val as string)} onChange={val => saveLocale(val as string)}

View file

@ -22,7 +22,7 @@ export function TimezoneSetting() {
}; };
return ( return (
<Row gap="3"> <Row gap>
<Select <Select
value={timezone} value={timezone}
onChange={(value: any) => saveTimezone(value)} onChange={(value: any) => saveTimezone(value)}

View file

@ -1,12 +1,12 @@
import { Row, Column, Label } from '@umami/react-zen'; import { Row, Column, Label } from '@umami/react-zen';
import { useLoginQuery, useMessages } from '@/components/hooks'; import { useConfig, useLoginQuery, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { PasswordChangeButton } from './PasswordChangeButton'; import { PasswordChangeButton } from './PasswordChangeButton';
export function ProfileSettings() { export function ProfileSettings() {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const cloudMode = !!process.env.cloudMode; const { cloudMode } = useConfig();
if (!user) { if (!user) {
return null; return null;

View file

@ -10,7 +10,7 @@ export function TeamProvider({ teamId, children }: { teamId?: string; children:
const { data: team, isLoading, isFetching } = useTeamQuery(teamId); const { data: team, isLoading, isFetching } = useTeamQuery(teamId);
if (isFetching && isLoading) { if (isFetching && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (!team) { if (!team) {

View file

@ -1,10 +1,10 @@
import { ReactNode } from 'react';
import Link from 'next/link';
import { DataGrid } from '@/components/common/DataGrid'; import { DataGrid } from '@/components/common/DataGrid';
import { TeamsTable } from './TeamsTable'; import { TeamsTable } from './TeamsTable';
import { useLoginQuery, useUserTeamsQuery } from '@/components/hooks'; import { useLoginQuery, useUserTeamsQuery } from '@/components/hooks';
import { ReactNode } from 'react';
export function TeamsDataTable({ export function TeamsDataTable({
allowEdit,
showActions, showActions,
}: { }: {
allowEdit?: boolean; allowEdit?: boolean;
@ -14,10 +14,18 @@ export function TeamsDataTable({
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const query = useUserTeamsQuery(user.id); const query = useUserTeamsQuery(user.id);
const renderLink = (row: any) => {
return (
<Link key={row.id} href={`/teams/${row.id}`}>
{row.name}
</Link>
);
};
return ( return (
<DataGrid query={query}> <DataGrid query={query}>
{({ data }) => { {({ data }) => {
return <TeamsTable data={data} allowEdit={allowEdit} showActions={showActions} />; return <TeamsTable data={data} showActions={showActions} renderLink={renderLink} />;
}} }}
</DataGrid> </DataGrid>
); );

View file

@ -1,14 +1,14 @@
import { Row } from '@umami/react-zen'; import { Row } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { useLoginQuery, useMessages } from '@/components/hooks'; import { useConfig, useLoginQuery, useMessages } from '@/components/hooks';
import { TeamsJoinButton } from './TeamsJoinButton'; import { TeamsJoinButton } from './TeamsJoinButton';
import { TeamsAddButton } from './TeamsAddButton'; import { TeamsAddButton } from './TeamsAddButton';
export function TeamsHeader({ allowCreate = true }: { allowCreate?: boolean }) { export function TeamsHeader({ allowCreate = true }: { allowCreate?: boolean }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const cloudMode = !!process.env.cloudMode; const { cloudMode } = useConfig();
return ( return (
<PageHeader title={formatMessage(labels.teams)}> <PageHeader title={formatMessage(labels.teams)}>

View file

@ -1,25 +1,25 @@
import { DataColumn, DataTable, Icon, MenuItem, Text, Row } from '@umami/react-zen'; import { DataColumn, DataTable, Icon, MenuItem, Text, Row } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { Eye, Edit } from '@/components/icons'; import { Eye, Edit } from '@/components/icons';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { MenuButton } from '@/components/input/MenuButton'; import { MenuButton } from '@/components/input/MenuButton';
import Link from 'next/link'; import { ReactNode } from 'react';
export function TeamsTable({ export function TeamsTable({
data = [], data = [],
showActions = false, showActions = false,
renderLink,
}: { }: {
data: any[]; data: any[];
allowEdit?: boolean;
showActions?: boolean; showActions?: boolean;
renderLink?: (row: any) => ReactNode;
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation();
return ( return (
<DataTable data={data}> <DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{(row: any) => <Link href={renderUrl(`/settings/teams/${row.id}`)}>{row.name}</Link>} {renderLink}
</DataColumn> </DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}> <DataColumn id="owner" label={formatMessage(labels.owner)}>
{(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username} {(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username}

View file

@ -16,7 +16,7 @@ export function WebsiteProvider({
const { data: website, isFetching, isLoading } = useWebsiteQuery(websiteId); const { data: website, isFetching, isLoading } = useWebsiteQuery(websiteId);
if (isFetching && isLoading) { if (isFetching && isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
if (!website) { if (!website) {

View file

@ -1,6 +1,7 @@
import Link from 'next/link';
import { WebsitesTable } from './WebsitesTable'; import { WebsitesTable } from './WebsitesTable';
import { DataGrid } from '@/components/common/DataGrid'; import { DataGrid } from '@/components/common/DataGrid';
import { useUserWebsitesQuery } from '@/components/hooks'; import { useNavigation, useUserWebsitesQuery } from '@/components/hooks';
export function WebsitesDataTable({ export function WebsitesDataTable({
teamId, teamId,
@ -14,16 +15,21 @@ export function WebsitesDataTable({
showActions?: boolean; showActions?: boolean;
}) { }) {
const queryResult = useUserWebsitesQuery({ teamId }); const queryResult = useUserWebsitesQuery({ teamId });
const { renderUrl } = useNavigation();
const renderLink = (row: any) => (
<Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
);
return ( return (
<DataGrid query={queryResult} allowSearch allowPaging> <DataGrid query={queryResult} allowSearch allowPaging>
{({ data }) => ( {({ data }) => (
<WebsitesTable <WebsitesTable
teamId={teamId}
data={data} data={data}
showActions={showActions} showActions={showActions}
allowEdit={allowEdit} allowEdit={allowEdit}
allowView={allowView} allowView={allowView}
renderLink={renderLink}
/> />
)} )}
</DataGrid> </DataGrid>

View file

@ -3,27 +3,24 @@ import { Row, Text, Icon, DataTable, DataColumn, MenuItem } from '@umami/react-z
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { MenuButton } from '@/components/input/MenuButton'; import { MenuButton } from '@/components/input/MenuButton';
import { Eye, SquarePen } from '@/components/icons'; import { Eye, SquarePen } from '@/components/icons';
import Link from 'next/link';
export interface WebsitesTableProps {
data: Record<string, any>[];
showActions?: boolean;
allowEdit?: boolean;
allowView?: boolean;
teamId?: string;
children?: ReactNode;
}
export function WebsitesTable({ export function WebsitesTable({
data = [], data = [],
showActions, showActions,
allowEdit, allowEdit,
allowView, allowView,
renderLink,
children, children,
}: WebsitesTableProps) { }: {
data: Record<string, any>[];
showActions?: boolean;
allowEdit?: boolean;
allowView?: boolean;
renderLink?: (row: any) => ReactNode;
children?: ReactNode;
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl, pathname } = useNavigation(); const { renderUrl } = useNavigation();
const isSettings = pathname.includes('/settings');
if (!data?.length) { if (!data?.length) {
return children; return children;
@ -32,11 +29,7 @@ export function WebsitesTable({
return ( return (
<DataTable data={data}> <DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{(row: any) => ( {renderLink}
<Link href={renderUrl(`${isSettings ? '/settings' : ''}/websites/${row.id}`, false)}>
{row.name}
</Link>
)}
</DataColumn> </DataColumn>
<DataColumn id="domain" label={formatMessage(labels.domain)} /> <DataColumn id="domain" label={formatMessage(labels.domain)} />
{showActions && ( {showActions && (

View file

@ -50,7 +50,7 @@ export function FunnelEditForm({
}; };
if (id && !data) { if (id && !data) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
const defaultValues = { const defaultValues = {

View file

@ -44,7 +44,7 @@ export function GoalEditForm({
}; };
if (id && !data) { if (id && !data) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
const defaultValues = { const defaultValues = {

View file

@ -52,7 +52,7 @@ export function CohortEditForm({
}; };
if (cohortId && !data) { if (cohortId && !data) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
const defaultValues = { const defaultValues = {

View file

@ -49,7 +49,7 @@ export function SegmentEditForm({
}; };
if (segmentId && !data) { if (segmentId && !data) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
return ( return (

View file

@ -64,7 +64,7 @@ export function WebsiteTransferForm({
}; };
if (isLoading) { if (isLoading) {
return <Loading icon="dots" position="center" />; return <Loading icon="dots" placement="center" />;
} }
return ( return (

View file

@ -1,23 +1,27 @@
'use server'; 'use server';
export type Config = { export type Config = {
cloudMode: boolean;
cloudUrl?: string;
faviconUrl?: string; faviconUrl?: string;
linksUrl?: string;
pixelsUrl?: string;
privateMode: boolean; privateMode: boolean;
telemetryDisabled: boolean; telemetryDisabled: boolean;
trackerScriptName?: string; trackerScriptName?: string;
updatesDisabled: boolean; updatesDisabled: boolean;
linksUrl?: string;
pixelsUrl?: string;
}; };
export async function getConfig(): Promise<Config> { export async function getConfig(): Promise<Config> {
return { return {
cloudMode: !!process.env.CLOUD_URL,
cloudUrl: process.env.CLOUD_URL,
faviconUrl: process.env.FAVICON_URL, faviconUrl: process.env.FAVICON_URL,
linksUrl: process.env.LINKS_URL,
pixelsUrl: process.env.PIXELS_URL,
privateMode: !!process.env.PRIVATE_MODE, privateMode: !!process.env.PRIVATE_MODE,
telemetryDisabled: !!process.env.DISABLE_TELEMETRY, telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME, trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
updatesDisabled: !!process.env.DISABLE_UPDATES, updatesDisabled: !!process.env.DISABLE_UPDATES,
linksUrl: process.env.LINKS_URL,
pixelsUrl: process.env.PIXELS_URL,
}; };
} }

View file

@ -8,7 +8,7 @@ import { removeClientAuthToken } from '@/lib/client';
export function LogoutPage() { export function LogoutPage() {
const router = useRouter(); const router = useRouter();
const { post } = useApi(); const { post } = useApi();
const disabled = process.env.cloudMode; const disabled = process.env.cloudUrl;
useEffect(() => { useEffect(() => {
async function logout() { async function logout() {

View file

@ -1,7 +1,7 @@
import { Row, Icon, Text, ThemeButton } from '@umami/react-zen'; import { Row, Icon, Text, ThemeButton } from '@umami/react-zen';
import Link from 'next/link'; import Link from 'next/link';
import { LanguageButton } from '@/components/input/LanguageButton'; import { LanguageButton } from '@/components/input/LanguageButton';
import { SettingsButton } from '@/components/input/SettingsButton'; import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/icons'; import { Logo } from '@/components/icons';
export function Header() { export function Header() {
@ -18,7 +18,7 @@ export function Header() {
<Row alignItems="center" gap> <Row alignItems="center" gap>
<ThemeButton /> <ThemeButton />
<LanguageButton /> <LanguageButton />
<SettingsButton /> <PreferencesButton />
</Row> </Row>
</Row> </Row>
); );

View file

@ -18,5 +18,5 @@ export function SSOPage() {
} }
}, [router, url, token]); }, [router, url, token]);
return <Loading position="page" />; return <Loading placement="absolute" />;
} }

View file

@ -10,7 +10,7 @@ export interface LoadingPanelProps extends ColumnProps {
isLoading?: boolean; isLoading?: boolean;
isFetching?: boolean; isFetching?: boolean;
loadingIcon?: 'dots' | 'spinner'; loadingIcon?: 'dots' | 'spinner';
loadingPosition?: 'center' | 'page' | 'inline'; loadingPlacement?: 'center' | 'absolute' | 'inline';
renderEmpty?: () => ReactNode; renderEmpty?: () => ReactNode;
children: ReactNode; children: ReactNode;
} }
@ -22,7 +22,7 @@ export function LoadingPanel({
isLoading, isLoading,
isFetching, isFetching,
loadingIcon = 'dots', loadingIcon = 'dots',
loadingPosition = 'page', loadingPlacement = 'absolute',
renderEmpty = () => <Empty />, renderEmpty = () => <Empty />,
children, children,
...props ...props
@ -34,7 +34,7 @@ export function LoadingPanel({
{/* Show loading spinner only if no data exists */} {/* Show loading spinner only if no data exists */}
{(isLoading || isFetching) && ( {(isLoading || isFetching) && (
<Column position="relative" height="100%" {...props}> <Column position="relative" height="100%" {...props}>
<Loading icon={loadingIcon} position={loadingPosition} /> <Loading icon={loadingIcon} placement={loadingPlacement} />
</Column> </Column>
)} )}

View file

@ -24,7 +24,7 @@ export function PageBody({
} }
if (isLoading) { if (isLoading) {
return <Loading position="page" />; return <Loading placement="absolute" />;
} }
return ( return (

View file

@ -1,6 +1,7 @@
export { export {
AlertTriangle as Alert, AlertTriangle as Alert,
ArrowRight as Arrow, ArrowRight as Arrow,
Bookmark,
Calendar, Calendar,
ChartPie, ChartPie,
ChevronRight as Chevron, ChevronRight as Chevron,
@ -50,4 +51,20 @@ export {
UserPlus, UserPlus,
X as Close, X as Close,
} from 'lucide-react'; } from 'lucide-react';
export * from '@/components/svg'; export {
Logo,
Bolt,
Change,
Compare,
Funnel,
Lightbulb,
Lightning,
Magnet,
Money,
Network,
Path,
Tag,
Target,
AddUser,
Visitor,
} from '@/components/svg';

View file

@ -1,12 +1,12 @@
import { useState, Key, Fragment } from 'react'; import { useState, Key, Fragment } from 'react';
import { Modal, Select, ListItem, ListSeparator, Dialog, Row } from '@umami/react-zen'; import { Modal, Select, ListItem, ListSeparator, Dialog, SelectProps } from '@umami/react-zen';
import { endOfYear } from 'date-fns'; import { endOfYear } from 'date-fns';
import { DatePickerForm } from '@/components/metrics/DatePickerForm'; import { DatePickerForm } from '@/components/metrics/DatePickerForm';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { DateDisplay } from '@/components/common/DateDisplay'; import { DateDisplay } from '@/components/common/DateDisplay';
import { parseDateRange } from '@/lib/date'; import { parseDateRange } from '@/lib/date';
export interface DateFilterProps { export interface DateFilterProps extends SelectProps {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
showAllTime?: boolean; showAllTime?: boolean;
@ -20,6 +20,7 @@ export function DateFilter({
showAllTime, showAllTime,
renderDate, renderDate,
placement = 'bottom', placement = 'bottom',
...props
}: DateFilterProps) { }: DateFilterProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
@ -99,8 +100,9 @@ export function DateFilter({
}; };
return ( return (
<Row minWidth="200px"> <>
<Select <Select
{...props}
value={value} value={value}
placeholder={formatMessage(labels.selectDate)} placeholder={formatMessage(labels.selectDate)}
onChange={handleChange} onChange={handleChange}
@ -130,6 +132,6 @@ export function DateFilter({
</Dialog> </Dialog>
</Modal> </Modal>
)} )}
</Row> </>
); );
} }

View file

@ -49,7 +49,7 @@ export function LookupField({ websiteId, type, value, onChange, ...props }: Look
allowsCustomValue allowsCustomValue
renderEmptyState={() => renderEmptyState={() =>
isLoading ? ( isLoading ? (
<Loading position="center" icon="dots" /> <Loading placement="center" icon="dots" />
) : ( ) : (
<Empty message={formatMessage(messages.noResultsFound)} /> <Empty message={formatMessage(messages.noResultsFound)} />
) )

View file

@ -0,0 +1,27 @@
import { Button, Icon, DialogTrigger, Popover, Column, Label } from '@umami/react-zen';
import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting';
import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting';
import { Settings } from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function PreferencesButton() {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button variant="quiet">
<Icon>
<Settings />
</Icon>
</Button>
<Popover placement="bottom end">
<Column gap="3">
<Label>{formatMessage(labels.timezone)}</Label>
<TimezoneSetting />
<Label>{formatMessage(labels.defaultDateRange)}</Label>
<DateRangeSetting />
</Column>
</Popover>
</DialogTrigger>
);
}

View file

@ -11,31 +11,31 @@ import {
Text, Text,
Row, Row,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useLoginQuery, useNavigation } from '@/components/hooks'; import { useMessages, useLoginQuery, useNavigation, useConfig } from '@/components/hooks';
import { LogOut, UserCircle, LockKeyhole } from '@/components/icons'; import { LogOut, UserCircle, LockKeyhole } from '@/components/icons';
export function ProfileButton() { export function ProfileButton() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const cloudMode = !!process.env.cloudMode; const { cloudUrl } = useConfig();
const items = [ const items = [
{ {
id: 'profile', id: 'settings',
label: formatMessage(labels.profile), label: formatMessage(labels.profile),
path: renderUrl('/settings/profile'), path: renderUrl('/settings/profile'),
icon: <UserCircle />, icon: <UserCircle />,
}, },
user.isAdmin && user.isAdmin &&
!cloudMode && { !cloudUrl && {
id: 'admin', id: 'admin',
label: formatMessage(labels.admin), label: formatMessage(labels.admin),
path: '/admin', path: '/admin',
icon: <LockKeyhole />, icon: <LockKeyhole />,
}, },
{ {
id: 'LogOut', id: 'logout',
label: formatMessage(labels.logout), label: formatMessage(labels.logout),
path: '/logout', path: '/logout',
icon: <LogOut />, icon: <LogOut />,

View file

@ -1,27 +1,71 @@
import { Button, Icon, DialogTrigger, Popover, Column, Label } from '@umami/react-zen'; import { Key } from 'react';
import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting'; import {
import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting'; Icon,
import { Gear } from '@/components/icons'; Button,
import { useMessages } from '@/components/hooks'; MenuTrigger,
Popover,
Menu,
MenuItem,
MenuSeparator,
MenuSection,
Dialog,
SubMenuTrigger,
} from '@umami/react-zen';
import { useMessages, useLoginQuery, useNavigation, useConfig } from '@/components/hooks';
import { LogOut, LockKeyhole, Settings, Knobs } from '@/components/icons';
import { PreferenceSettings } from '@/app/(main)/settings/preferences/PreferenceSettings';
export function SettingsButton() { export function SettingsButton() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery();
const { router, renderUrl } = useNavigation();
const { cloudMode, cloudUrl } = useConfig();
const handleAction = (id: Key) => {
if (id === 'settings') {
if (cloudMode) {
window.location.href = `${cloudUrl}/dashboard`;
return;
}
}
router.push(renderUrl(`/${id}`));
};
return ( return (
<DialogTrigger> <MenuTrigger>
<Button variant="quiet"> <Button data-test="button-profile" variant="quiet" autoFocus={false}>
<Icon> <Icon>
<Gear /> <Settings />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom end"> <Popover placement="bottom end">
<Column gap="3"> <Menu autoFocus="last" onAction={handleAction}>
<Label>{formatMessage(labels.timezone)}</Label> <MenuSection title={user.username}>
<TimezoneSetting /> <MenuSeparator />
<Label>{formatMessage(labels.defaultDateRange)}</Label> <MenuItem id="settings" icon={<Settings />} label={formatMessage(labels.settings)} />
<DateRangeSetting /> {cloudMode && (
</Column> <SubMenuTrigger>
<MenuItem
icon={<Knobs />}
label={formatMessage(labels.preferences)}
showSubMenuIcon
/>
<Popover placement="right bottom">
<Dialog>
<PreferenceSettings />
</Dialog>
</Popover>
</SubMenuTrigger>
)}
{!cloudMode && user.isAdmin && (
<MenuItem id="admin" icon={<LockKeyhole />} label={formatMessage(labels.admin)} />
)}
<MenuSeparator />
<MenuItem id="logout" icon={<LogOut />} label={formatMessage(labels.logout)} />
</MenuSection>
</Menu>
</Popover> </Popover>
</DialogTrigger> </MenuTrigger>
); );
} }

View file

@ -1,5 +1,3 @@
import { Key } from 'react';
import { useRouter } from 'next/navigation';
import { import {
Text, Text,
Icon, Icon,
@ -10,8 +8,8 @@ import {
MenuSeparator, MenuSeparator,
Popover, Popover,
Row, Row,
Box, Column,
Button, Pressable,
Loading, Loading,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useLoginQuery, useMessages, useUserTeamsQuery, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useUserTeamsQuery, useNavigation } from '@/components/hooks';
@ -32,7 +30,7 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
const label = teamId ? team?.name : user.username; const label = teamId ? team?.name : user.username;
if (isLoading) { if (isLoading) {
return <Loading icon="dots" position="center" />; return <Loading icon="dots" size="sm" placement="center" />;
} }
if (!data?.count) { if (!data?.count) {
@ -41,8 +39,15 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
return ( return (
<MenuTrigger> <MenuTrigger>
<Button variant="outline"> <Pressable>
<Row alignItems="center" justifyContent="space-between" flexGrow={1}> <Row
alignItems="center"
justifyContent="space-between"
flexGrow={1}
padding
backgroundColor="2"
style={{ cursor: 'pointer', textWrap: 'nowrap', outline: 'none' }}
>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Icon>{teamId ? <Users /> : <User />}</Icon> <Icon>{teamId ? <Users /> : <User />}</Icon>
{showText && <Text>{label}</Text>} {showText && <Text>{label}</Text>}
@ -53,9 +58,9 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
</Icon> </Icon>
)} )}
</Row> </Row>
</Button> </Pressable>
<Popover placement="bottom start"> <Popover placement="bottom start">
<Box minWidth="300px"> <Column minWidth="300px">
<Menu <Menu
selectionMode="single" selectionMode="single"
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
@ -86,7 +91,7 @@ export function TeamsButton({ showText = true, onAction }: TeamsButtonProps) {
))} ))}
</MenuSection> </MenuSection>
</Menu> </Menu>
</Box> </Column>
</Popover> </Popover>
</MenuTrigger> </MenuTrigger>
); );

View file

@ -59,12 +59,14 @@ export function WebsiteDateFilter({
</Button> </Button>
</Row> </Row>
)} )}
<DateFilter <Row width="200px">
value={value} <DateFilter
onChange={handleChange} value={value}
showAllTime={showAllTime} onChange={handleChange}
renderDate={+offset !== 0} showAllTime={showAllTime}
/> renderDate={+offset !== 0}
/>
</Row>
{allowCompare && !isAllTime && ( {allowCompare && !isAllTime && (
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Text weight="bold">VS</Text> <Text weight="bold">VS</Text>

View file

@ -3,7 +3,7 @@ import { PERMISSIONS } from '@/lib/constants';
import { getTeamUser } from '@/queries'; import { getTeamUser } from '@/queries';
import { hasPermission } from '@/lib/auth'; import { hasPermission } from '@/lib/auth';
const cloudMode = !!process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_URL;
export async function canViewTeam({ user }: Auth, teamId: string) { export async function canViewTeam({ user }: Auth, teamId: string) {
if (user.isAdmin) { if (user.isAdmin) {

View file

@ -3,7 +3,7 @@ import { PERMISSIONS } from '@/lib/constants';
import { hasPermission } from '@/lib/auth'; import { hasPermission } from '@/lib/auth';
import { getTeamUser, getWebsite } from '@/queries'; import { getTeamUser, getWebsite } from '@/queries';
const cloudMode = !!process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_URL;
export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) { export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
if (user?.isAdmin) { if (user?.isAdmin) {

View file

@ -117,7 +117,7 @@ export async function deleteTeam(
teamId: string, teamId: string,
): Promise<Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Team]>> { ): Promise<Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Team]>> {
const { client, transaction } = prisma; const { client, transaction } = prisma;
const cloudMode = process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_URL;
if (cloudMode) { if (cloudMode) {
return transaction([ return transaction([

View file

@ -122,7 +122,7 @@ export async function deleteUser(
] ]
> { > {
const { client, transaction } = prisma; const { client, transaction } = prisma;
const cloudMode = process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_URL;
const websites = await client.website.findMany({ const websites = await client.website.findMany({
where: { userId }, where: { userId },

View file

@ -159,7 +159,7 @@ export async function resetWebsite(
websiteId: string, websiteId: string,
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> { ): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
const { client, transaction } = prisma; const { client, transaction } = prisma;
const cloudMode = !!process.env.cloudMode; const cloudMode = !!process.env.CLOUD_URL;
return transaction([ return transaction([
client.eventData.deleteMany({ client.eventData.deleteMany({
@ -193,7 +193,7 @@ export async function deleteWebsite(
websiteId: string, websiteId: string,
): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> { ): Promise<[Prisma.BatchPayload, Prisma.BatchPayload, Website]> {
const { client, transaction } = prisma; const { client, transaction } = prisma;
const cloudMode = !!process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_URL;
return transaction([ return transaction([
client.eventData.deleteMany({ client.eventData.deleteMany({