mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 20:15:41 +01:00
Compare commits
6 commits
a8e3b5efbc
...
ac9edb8b5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9edb8b5f | ||
|
|
8aa4192576 | ||
|
|
e4ce7c9071 | ||
|
|
038f397503 | ||
|
|
654156d239 | ||
|
|
64b5a72963 |
29 changed files with 1186 additions and 1290 deletions
|
|
@ -78,7 +78,7 @@
|
||||||
"@react-spring/web": "^10.0.3",
|
"@react-spring/web": "^10.0.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@umami/react-zen": "^0.187.0",
|
"@umami/react-zen": "^0.189.0",
|
||||||
"@umami/redis-client": "^0.29.0",
|
"@umami/redis-client": "^0.29.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
|
|
||||||
1772
pnpm-lock.yaml
generated
1772
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,30 +7,12 @@ import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
||||||
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
|
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
|
||||||
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
|
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
|
||||||
import { Column, Dialog, Grid, Modal } from '@umami/react-zen';
|
import { Column, Grid } from '@umami/react-zen';
|
||||||
import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
|
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||||
import { useNavigation } from '@/components/hooks';
|
|
||||||
|
|
||||||
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
||||||
|
|
||||||
export function LinkPage({ linkId }: { linkId: string }) {
|
export function LinkPage({ linkId }: { linkId: string }) {
|
||||||
const {
|
|
||||||
router,
|
|
||||||
query: { view },
|
|
||||||
updateParams,
|
|
||||||
} = useNavigation();
|
|
||||||
|
|
||||||
const handleClose = (close: () => void) => {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkProvider linkId={linkId}>
|
<LinkProvider linkId={linkId}>
|
||||||
<Grid width="100%" height="100%">
|
<Grid width="100%" height="100%">
|
||||||
|
|
@ -44,19 +26,7 @@ export function LinkPage({ linkId }: { linkId: string }) {
|
||||||
</Panel>
|
</Panel>
|
||||||
<LinkPanels linkId={linkId} />
|
<LinkPanels linkId={linkId} />
|
||||||
</PageBody>
|
</PageBody>
|
||||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
<ExpandedViewModal websiteId={linkId} excludedIds={excludedIds} />
|
||||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
|
||||||
{({ close }) => {
|
|
||||||
return (
|
|
||||||
<WebsiteExpandedView
|
|
||||||
websiteId={linkId}
|
|
||||||
excludedIds={excludedIds}
|
|
||||||
onClose={() => handleClose(close)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
</LinkProvider>
|
</LinkProvider>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function LinkPanels({ linkId }: { linkId: string }) {
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<GridRow layout="two" {...rowProps}>
|
<GridRow layout="two" {...rowProps}>
|
||||||
<Panel noPadding>
|
<Panel padding="0">
|
||||||
<WorldMap websiteId={linkId} />
|
<WorldMap websiteId={linkId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
|
||||||
|
|
@ -7,30 +7,12 @@ import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
||||||
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
|
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
|
||||||
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
|
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
|
||||||
import { Column, Dialog, Grid, Modal } from '@umami/react-zen';
|
import { Column, Grid } from '@umami/react-zen';
|
||||||
import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
|
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||||
import { useNavigation } from '@/components/hooks';
|
|
||||||
|
|
||||||
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
|
||||||
|
|
||||||
export function PixelPage({ pixelId }: { pixelId: string }) {
|
export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||||
const {
|
|
||||||
router,
|
|
||||||
query: { view },
|
|
||||||
updateParams,
|
|
||||||
} = useNavigation();
|
|
||||||
|
|
||||||
const handleClose = (close: () => void) => {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PixelProvider pixelId={pixelId}>
|
<PixelProvider pixelId={pixelId}>
|
||||||
<Grid width="100%" height="100%">
|
<Grid width="100%" height="100%">
|
||||||
|
|
@ -44,19 +26,7 @@ export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||||
</Panel>
|
</Panel>
|
||||||
<PixelPanels pixelId={pixelId} />
|
<PixelPanels pixelId={pixelId} />
|
||||||
</PageBody>
|
</PageBody>
|
||||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
<ExpandedViewModal websiteId={pixelId} excludedIds={excludedIds} />
|
||||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
|
||||||
{({ close }) => {
|
|
||||||
return (
|
|
||||||
<WebsiteExpandedView
|
|
||||||
websiteId={pixelId}
|
|
||||||
excludedIds={excludedIds}
|
|
||||||
onClose={() => handleClose(close)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
</PixelProvider>
|
</PixelProvider>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function PixelPanels({ pixelId }: { pixelId: string }) {
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<GridRow layout="two" {...rowProps}>
|
<GridRow layout="two" {...rowProps}>
|
||||||
<Panel noPadding>
|
<Panel padding="0">
|
||||||
<WorldMap websiteId={pixelId} />
|
<WorldMap websiteId={pixelId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
|
||||||
44
src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
Normal file
44
src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Dialog, Modal } from '@umami/react-zen';
|
||||||
|
import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
|
||||||
|
import { useNavigation } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function ExpandedViewModal({
|
||||||
|
websiteId,
|
||||||
|
excludedIds,
|
||||||
|
}: {
|
||||||
|
websiteId: string;
|
||||||
|
excludedIds?: string[];
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
router,
|
||||||
|
query: { view },
|
||||||
|
updateParams,
|
||||||
|
} = useNavigation();
|
||||||
|
|
||||||
|
const handleClose = (close: () => void) => {
|
||||||
|
router.push(updateParams({ view: undefined }));
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
router.push(updateParams({ view: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
||||||
|
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
||||||
|
{({ close }) => {
|
||||||
|
return (
|
||||||
|
<WebsiteExpandedView
|
||||||
|
websiteId={websiteId}
|
||||||
|
excludedIds={excludedIds}
|
||||||
|
onClose={() => handleClose(close)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Column, Row } from '@umami/react-zen';
|
import { Column, Row, Grid } from '@umami/react-zen';
|
||||||
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
|
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
|
||||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||||
import { FilterBar } from '@/components/input/FilterBar';
|
import { FilterBar } from '@/components/input/FilterBar';
|
||||||
|
|
@ -22,12 +22,18 @@ export function WebsiteControls({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
|
||||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
<Row alignItems="center" justifyContent="flex-end">
|
||||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
|
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||||
{allowDownload && <ExportButton websiteId={websiteId} />}
|
</Row>
|
||||||
{allowMonthFilter && <MonthFilter />}
|
<Row alignItems="center" justifyContent="flex-end">
|
||||||
</Row>
|
{allowDateFilter && (
|
||||||
|
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
|
||||||
|
)}
|
||||||
|
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||||
|
{allowMonthFilter && <MonthFilter />}
|
||||||
|
</Row>
|
||||||
|
</Grid>
|
||||||
{allowFilter && <FilterBar websiteId={websiteId} />}
|
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
||||||
<ActiveUsers websiteId={website.id} />
|
<ActiveUsers websiteId={website.id} />
|
||||||
|
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<Row alignItems="center" gap>
|
<Row
|
||||||
|
display={{ xs: 'none', sm: 'none', md: 'none', lg: 'flex', xl: 'flex' }}
|
||||||
|
alignItems="center"
|
||||||
|
gap
|
||||||
|
>
|
||||||
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
||||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||||
<Icon>
|
<Icon>
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,23 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Column, Grid } from '@umami/react-zen';
|
import { Column, Grid } from '@umami/react-zen';
|
||||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { useNavigation } from '@/components/hooks';
|
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
import { WebsiteHeader } from './WebsiteHeader';
|
import { WebsiteHeader } from './WebsiteHeader';
|
||||||
import { WebsiteNav } from './WebsiteNav';
|
import { WebsiteNav } from './WebsiteNav';
|
||||||
|
|
||||||
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||||
const { pathname } = useNavigation();
|
|
||||||
|
|
||||||
const isSettings = pathname.endsWith('/settings');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebsiteProvider websiteId={websiteId}>
|
<WebsiteProvider websiteId={websiteId}>
|
||||||
<Grid columns={isSettings ? '1fr' : 'auto 1fr'} width="100%" height="100%">
|
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
|
||||||
{!isSettings && (
|
<Column
|
||||||
<Column height="100%" border="right" backgroundColor marginRight="2">
|
display={{ xs: 'none', lg: 'flex' }}
|
||||||
<WebsiteNav websiteId={websiteId} />
|
height="100%"
|
||||||
</Column>
|
border="right"
|
||||||
)}
|
backgroundColor
|
||||||
|
marginRight="2"
|
||||||
|
>
|
||||||
|
<WebsiteNav websiteId={websiteId} />
|
||||||
|
</Column>
|
||||||
<PageBody gap>
|
<PageBody gap>
|
||||||
<WebsiteHeader showActions />
|
<WebsiteHeader showActions />
|
||||||
<Column>{children}</Column>
|
<Column>{children}</Column>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column, Modal, Dialog } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { useNavigation } from '@/components/hooks';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { WebsiteChart } from './WebsiteChart';
|
import { WebsiteChart } from './WebsiteChart';
|
||||||
import { WebsiteExpandedView } from './WebsiteExpandedView';
|
|
||||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||||
import { WebsitePanels } from './WebsitePanels';
|
import { WebsitePanels } from './WebsitePanels';
|
||||||
import { WebsiteControls } from './WebsiteControls';
|
import { WebsiteControls } from './WebsiteControls';
|
||||||
|
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||||
|
|
||||||
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
const {
|
|
||||||
router,
|
|
||||||
query: { view },
|
|
||||||
updateParams,
|
|
||||||
} = useNavigation();
|
|
||||||
const handleClose = (close: () => void) => {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
router.push(updateParams({ view: undefined }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
|
@ -33,13 +16,7 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
<WebsiteChart websiteId={websiteId} />
|
<WebsiteChart websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<WebsitePanels websiteId={websiteId} />
|
<WebsitePanels websiteId={websiteId} />
|
||||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
<ExpandedViewModal websiteId={websiteId} />
|
||||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
|
||||||
{({ close }) => {
|
|
||||||
return <WebsiteExpandedView websiteId={websiteId} onClose={() => handleClose(close)} />;
|
|
||||||
}}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading, Row } from '@umami/react-zen';
|
|
||||||
import { GridRow } from '@/components/common/GridRow';
|
import { GridRow } from '@/components/common/GridRow';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
|
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||||
|
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||||
|
|
||||||
export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { pathname } = useNavigation();
|
||||||
const tableProps = {
|
const tableProps = {
|
||||||
websiteId,
|
websiteId,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
|
@ -16,6 +18,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
metric: formatMessage(labels.visitors),
|
metric: formatMessage(labels.visitors),
|
||||||
};
|
};
|
||||||
const rowProps = { minHeight: '570px' };
|
const rowProps = { minHeight: '570px' };
|
||||||
|
const isSharePage = pathname.includes('/share/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid gap="3">
|
<Grid gap="3">
|
||||||
|
|
@ -103,7 +106,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
</GridRow>
|
</GridRow>
|
||||||
|
|
||||||
<GridRow layout="two-one" {...rowProps}>
|
<GridRow layout="two-one" {...rowProps}>
|
||||||
<Panel gridColumn="span 2" noPadding>
|
<Panel gridColumn={{ xs: 'span 1', md: 'span 2' }} padding="0">
|
||||||
<WorldMap websiteId={websiteId} />
|
<WorldMap websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
@ -113,6 +116,25 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
<WeeklyTraffic websiteId={websiteId} />
|
<WeeklyTraffic websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
|
{isSharePage && (
|
||||||
|
<GridRow layout="two-one" {...rowProps}>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.events)}</Heading>
|
||||||
|
<Row border="bottom" marginBottom="4" />
|
||||||
|
<MetricsTable
|
||||||
|
websiteId={websiteId}
|
||||||
|
type="event"
|
||||||
|
title={formatMessage(labels.event)}
|
||||||
|
metric={formatMessage(labels.count)}
|
||||||
|
limit={15}
|
||||||
|
filterLink={false}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}>
|
||||||
|
<EventsChart websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function EventsPage({ websiteId }) {
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
type="event"
|
type="event"
|
||||||
title={formatMessage(labels.events)}
|
title={formatMessage(labels.event)}
|
||||||
metric={formatMessage(labels.count)}
|
metric={formatMessage(labels.count)}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { IconLabel } from '@umami/react-zen';
|
||||||
import { ListTable } from '@/components/metrics/ListTable';
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
import { useLocale, useCountryNames, useMessages } from '@/components/hooks';
|
import { useLocale, useCountryNames, useMessages } from '@/components/hooks';
|
||||||
import classNames from 'classnames';
|
|
||||||
import styles from './RealtimeCountries.module.css';
|
|
||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||||
|
|
||||||
export function RealtimeCountries({ data }) {
|
export function RealtimeCountries({ data }) {
|
||||||
|
|
@ -12,10 +11,7 @@ export function RealtimeCountries({ data }) {
|
||||||
|
|
||||||
const renderCountryName = useCallback(
|
const renderCountryName = useCallback(
|
||||||
({ label: code }) => (
|
({ label: code }) => (
|
||||||
<span className={classNames(styles.row)}>
|
<IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} />
|
||||||
<TypeIcon type="country" value={code} />
|
|
||||||
{countryNames[code]}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
[countryNames, locale],
|
[countryNames, locale],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
justify-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.card {
|
|
||||||
flex-basis: calc(50% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import { RealtimeData } from '@/lib/types';
|
|
||||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||||
|
|
||||||
export function RealtimeHeader({ data }: { data: RealtimeData }) {
|
export function RealtimeHeader({ data }: { data: any }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { totals }: any = data || {};
|
const { totals }: any = data || {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
.table {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
line-height: 40px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
height: 50px;
|
|
||||||
border-bottom: 1px solid var(--base300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
overflow: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-inline-end: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
min-width: 60px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
gap: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail > span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .link {
|
|
||||||
color: var(--base900);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .link:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 992px) {
|
|
||||||
.actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { FixedSizeList } from 'react-window';
|
||||||
|
import { SearchField, Text, Column, Row, IconLabel, Heading } from '@umami/react-zen';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useFormat } from '@/components//hooks/useFormat';
|
import { useFormat } from '@/components//hooks/useFormat';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||||
|
|
@ -5,17 +9,15 @@ import {
|
||||||
useCountryNames,
|
useCountryNames,
|
||||||
useLocale,
|
useLocale,
|
||||||
useMessages,
|
useMessages,
|
||||||
|
useNavigation,
|
||||||
useTimezone,
|
useTimezone,
|
||||||
useWebsite,
|
useWebsite,
|
||||||
} from '@/components/hooks';
|
} from '@/components/hooks';
|
||||||
import { Eye, User } from '@/components/icons';
|
import { Eye, User } from '@/components/icons';
|
||||||
import { Lightning } from '@/components/svg';
|
import { Lightning } from '@/components/svg';
|
||||||
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
||||||
import { stringToColor } from '@/lib/format';
|
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
|
||||||
import { useMemo, useState } from 'react';
|
import { Avatar } from '@/components/common/Avatar';
|
||||||
import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
|
|
||||||
import { FixedSizeList } from 'react-window';
|
|
||||||
import styles from './RealtimeLog.module.css';
|
|
||||||
|
|
||||||
const TYPE_ALL = 'all';
|
const TYPE_ALL = 'all';
|
||||||
const TYPE_PAGEVIEW = 'pageview';
|
const TYPE_PAGEVIEW = 'pageview';
|
||||||
|
|
@ -37,6 +39,7 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
const { formatTimezoneDate } = useTimezone();
|
const { formatTimezoneDate } = useTimezone();
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
const [filter, setFilter] = useState(TYPE_ALL);
|
const [filter, setFilter] = useState(TYPE_ALL);
|
||||||
|
const { updateParams } = useNavigation();
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{
|
{
|
||||||
|
|
@ -59,8 +62,6 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
|
|
||||||
const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
|
const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
|
||||||
|
|
||||||
const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
|
|
||||||
|
|
||||||
const getIcon = ({ __type }) => icons[__type];
|
const getIcon = ({ __type }) => icons[__type];
|
||||||
|
|
||||||
const getDetail = (log: {
|
const getDetail = (log: {
|
||||||
|
|
@ -84,7 +85,6 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
<a
|
<a
|
||||||
key="a"
|
key="a"
|
||||||
href={`//${website?.domain}${urlPath}`}
|
href={`//${website?.domain}${urlPath}`}
|
||||||
className={styles.link}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
|
|
@ -98,12 +98,7 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
|
|
||||||
if (__type === TYPE_PAGEVIEW) {
|
if (__type === TYPE_PAGEVIEW) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
|
||||||
href={`//${website?.domain}${urlPath}`}
|
|
||||||
className={styles.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
{urlPath}
|
{urlPath}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
@ -124,19 +119,18 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const Row = ({ index, style }) => {
|
const TableRow = ({ index, style }) => {
|
||||||
const row = logs[index];
|
const row = logs[index];
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} style={style}>
|
<Row alignItems="center" style={style} gap>
|
||||||
<div>
|
<Link href={updateParams({ session: row.sessionId })}>
|
||||||
<StatusLight color={getColor(row)} />
|
<Avatar seed={row.sessionId} size={32} />
|
||||||
</div>
|
</Link>
|
||||||
<div className={styles.time}>{getTime(row)}</div>
|
<Row width="100px">{getTime(row)}</Row>
|
||||||
<div className={styles.detail}>
|
<IconLabel icon={getIcon(row)}>
|
||||||
<Icon className={styles.icon}>{getIcon(row)}</Icon>
|
|
||||||
<Text>{getDetail(row)}</Text>
|
<Text>{getDetail(row)}</Text>
|
||||||
</div>
|
</IconLabel>
|
||||||
</div>
|
</Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -172,20 +166,21 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
}, [data, filter, formatValue, search]);
|
}, [data, filter, formatValue, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.table}>
|
<Column gap>
|
||||||
<div className={styles.actions}>
|
<Heading size="2">{formatMessage(labels.activity)}</Heading>
|
||||||
<SearchField className={styles.search} value={search} onSearch={setSearch} />
|
<Row alignItems="center" justifyContent="space-between">
|
||||||
|
<SearchField value={search} onSearch={setSearch} />
|
||||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||||
</div>
|
</Row>
|
||||||
<div className={styles.header}>{formatMessage(labels.activity)}</div>
|
<Column>
|
||||||
<div className={styles.body}>
|
|
||||||
{logs?.length === 0 && <Empty />}
|
{logs?.length === 0 && <Empty />}
|
||||||
{logs?.length > 0 && (
|
{logs?.length > 0 && (
|
||||||
<FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
|
<FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
|
||||||
{Row}
|
{TableRow}
|
||||||
</FixedSizeList>
|
</FixedSizeList>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Column>
|
||||||
</div>
|
<SessionModal websiteId={website.id} />
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import { WorldMap } from '@/components/metrics/WorldMap';
|
||||||
import { useRealtimeQuery } from '@/components/hooks';
|
import { useRealtimeQuery } from '@/components/hooks';
|
||||||
import { RealtimeLog } from './RealtimeLog';
|
import { RealtimeLog } from './RealtimeLog';
|
||||||
import { RealtimeHeader } from './RealtimeHeader';
|
import { RealtimeHeader } from './RealtimeHeader';
|
||||||
import { RealtimeUrls } from './RealtimeUrls';
|
import { RealtimePaths } from './RealtimePaths';
|
||||||
|
import { RealtimeReferrers } from './RealtimeReferrers';
|
||||||
import { RealtimeCountries } from './RealtimeCountries';
|
import { RealtimeCountries } from './RealtimeCountries';
|
||||||
import { percentFilter } from '@/lib/filters';
|
import { percentFilter } from '@/lib/filters';
|
||||||
|
|
||||||
|
|
@ -32,19 +33,22 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
|
||||||
<Panel>
|
<Panel>
|
||||||
<RealtimeChart data={data} unit="minute" />
|
<RealtimeChart data={data} unit="minute" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<RealtimeLog data={data} />
|
||||||
|
</Panel>
|
||||||
<GridRow layout="two">
|
<GridRow layout="two">
|
||||||
<Panel>
|
<Panel>
|
||||||
<RealtimeUrls data={data} />
|
<RealtimePaths data={data} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
<RealtimeLog data={data} />
|
<RealtimeReferrers data={data} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<GridRow layout="one-two">
|
<GridRow layout="one-two">
|
||||||
<Panel>
|
<Panel>
|
||||||
<RealtimeCountries data={countries} />
|
<RealtimeCountries data={countries} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel gridColumn="span 2" noPadding>
|
<Panel gridColumn="span 2" padding="0">
|
||||||
<WorldMap data={countries} />
|
<WorldMap data={countries} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import thenby from 'thenby';
|
||||||
|
import { percentFilter } from '@/lib/filters';
|
||||||
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
|
import { useMessages, useWebsite } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function RealtimePaths({ data }: { data: any }) {
|
||||||
|
const website = useWebsite();
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { urls } = data || {};
|
||||||
|
const limit = 15;
|
||||||
|
|
||||||
|
const renderLink = ({ label: x }) => {
|
||||||
|
const domain = x.startsWith('/') ? website?.domain : '';
|
||||||
|
return (
|
||||||
|
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||||
|
{x}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = percentFilter(
|
||||||
|
Object.keys(urls)
|
||||||
|
.map(key => {
|
||||||
|
return {
|
||||||
|
x: key,
|
||||||
|
y: urls[key],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(thenby.firstBy('y', -1))
|
||||||
|
.slice(0, limit),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTable
|
||||||
|
title={formatMessage(labels.pages)}
|
||||||
|
metric={formatMessage(labels.views)}
|
||||||
|
renderLabel={renderLink}
|
||||||
|
data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||||
|
label: x,
|
||||||
|
count: y,
|
||||||
|
percent: z,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import thenby from 'thenby';
|
||||||
|
import { percentFilter } from '@/lib/filters';
|
||||||
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
|
import { useMessages, useWebsite } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function RealtimeReferrers({ data }: { data: any }) {
|
||||||
|
const website = useWebsite();
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { referrers } = data || {};
|
||||||
|
const limit = 15;
|
||||||
|
|
||||||
|
const renderLink = ({ label: x }) => {
|
||||||
|
const domain = x.startsWith('/') ? website?.domain : '';
|
||||||
|
return (
|
||||||
|
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||||
|
{x}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const domains = percentFilter(
|
||||||
|
Object.keys(referrers)
|
||||||
|
.map(key => {
|
||||||
|
return {
|
||||||
|
x: key,
|
||||||
|
y: referrers[key],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(thenby.firstBy('y', -1))
|
||||||
|
.slice(0, limit),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTable
|
||||||
|
title={formatMessage(labels.referrers)}
|
||||||
|
metric={formatMessage(labels.views)}
|
||||||
|
renderLabel={renderLink}
|
||||||
|
data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||||
|
label: x,
|
||||||
|
count: y,
|
||||||
|
percent: z,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Row } from '@umami/react-zen';
|
|
||||||
import thenby from 'thenby';
|
|
||||||
import { percentFilter } from '@/lib/filters';
|
|
||||||
import { ListTable } from '@/components/metrics/ListTable';
|
|
||||||
import { useMessages, useWebsite } from '@/components/hooks';
|
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
|
||||||
|
|
||||||
const FILTER_REFERRERS = 'filter-referrers';
|
|
||||||
const FILTER_PAGES = 'filter-pages';
|
|
||||||
|
|
||||||
export function RealtimeUrls({ data }: { data: any }) {
|
|
||||||
const website = useWebsite();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { referrers, urls } = data || {};
|
|
||||||
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
|
||||||
const limit = 15;
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
id: FILTER_REFERRERS,
|
|
||||||
label: formatMessage(labels.referrers),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: FILTER_PAGES,
|
|
||||||
label: formatMessage(labels.pages),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderLink = ({ label: x }) => {
|
|
||||||
const domain = x.startsWith('/') ? website?.domain : '';
|
|
||||||
return (
|
|
||||||
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
|
||||||
{x}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const domains = percentFilter(
|
|
||||||
Object.keys(referrers)
|
|
||||||
.map(key => {
|
|
||||||
return {
|
|
||||||
x: key,
|
|
||||||
y: referrers[key],
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(thenby.firstBy('y', -1))
|
|
||||||
.slice(0, limit),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pages = percentFilter(
|
|
||||||
Object.keys(urls)
|
|
||||||
.map(key => {
|
|
||||||
return {
|
|
||||||
x: key,
|
|
||||||
y: urls[key],
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(thenby.firstBy('y', -1))
|
|
||||||
.slice(0, limit),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Row justifyContent="center">
|
|
||||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
|
||||||
</Row>
|
|
||||||
{filter === FILTER_REFERRERS && (
|
|
||||||
<ListTable
|
|
||||||
title={formatMessage(labels.referrers)}
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
|
||||||
label: x,
|
|
||||||
count: y,
|
|
||||||
percent: z,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filter === FILTER_PAGES && (
|
|
||||||
<ListTable
|
|
||||||
title={formatMessage(labels.pages)}
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
|
||||||
label: x,
|
|
||||||
count: y,
|
|
||||||
percent: z,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
'use client';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { createAvatar } from '@dicebear/core';
|
import { createAvatar } from '@dicebear/core';
|
||||||
import { lorelei } from '@dicebear/collection';
|
import { lorelei } from '@dicebear/collection';
|
||||||
|
|
@ -6,41 +5,17 @@ import { getColor, getPastel } from '@/lib/colors';
|
||||||
|
|
||||||
const lib = lorelei;
|
const lib = lorelei;
|
||||||
|
|
||||||
// ✅ Modern UTF-8 safe base64 encoder (no deprecated APIs)
|
|
||||||
function toBase64(str: string): string {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
// Server (Node.js)
|
|
||||||
return Buffer.from(str, 'utf-8').toString('base64');
|
|
||||||
} else {
|
|
||||||
// Browser (UTF-8 safe)
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const bytes = encoder.encode(str);
|
|
||||||
let binary = '';
|
|
||||||
const chunkSize = 0x8000;
|
|
||||||
|
|
||||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
||||||
const chunk = bytes.subarray(i, i + chunkSize);
|
|
||||||
binary += String.fromCharCode(...chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
|
export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
|
||||||
const backgroundColor = getPastel(getColor(seed), 4);
|
const backgroundColor = getPastel(getColor(seed), 4);
|
||||||
|
|
||||||
const avatar = useMemo(() => {
|
const avatar = useMemo(() => {
|
||||||
const svg = createAvatar(lib, {
|
return createAvatar(lib, {
|
||||||
...props,
|
...props,
|
||||||
seed,
|
seed,
|
||||||
size,
|
size,
|
||||||
backgroundColor: [backgroundColor],
|
backgroundColor: [backgroundColor],
|
||||||
}).toString();
|
}).toDataUri();
|
||||||
|
}, []);
|
||||||
const base64 = toBase64(svg);
|
|
||||||
return `data:image/svg+xml;base64,${base64}`;
|
|
||||||
}, [seed, size, backgroundColor, props]);
|
|
||||||
|
|
||||||
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />;
|
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,11 @@ const LAYOUTS = {
|
||||||
md: 'repeat(auto-fill, minmax(360px, 1fr))',
|
md: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'one-two': { columns: { xs: '1fr', lg: 'repeat(3, 1fr)' } },
|
'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
|
||||||
'two-one': { columns: { xs: '1fr', lg: 'repeat(3, 1fr)' } },
|
'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GridRow(props: {
|
export function GridRow(props: {
|
||||||
[x: string]: any;
|
|
||||||
layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare';
|
layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare';
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: any;
|
children?: any;
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,14 @@ export function PageBody({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column {...props} width="100%" paddingBottom="9" style={{ margin: '0 auto', maxWidth }}>
|
<Column
|
||||||
|
{...props}
|
||||||
|
width="100%"
|
||||||
|
paddingBottom="9"
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
paddingX="4"
|
||||||
|
style={{ margin: '0 auto' }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { useMessages } from '@/components/hooks';
|
||||||
export interface PanelProps extends ColumnProps {
|
export interface PanelProps extends ColumnProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
allowFullscreen?: boolean;
|
allowFullscreen?: boolean;
|
||||||
noPadding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullscreenStyles = {
|
const fullscreenStyles = {
|
||||||
|
|
@ -28,14 +27,7 @@ const fullscreenStyles = {
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
export function Panel({
|
export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) {
|
||||||
title,
|
|
||||||
allowFullscreen,
|
|
||||||
noPadding,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: PanelProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -45,7 +37,7 @@ export function Panel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
padding={!noPadding ? '6' : undefined}
|
padding={{ xs: '3', md: '6' }}
|
||||||
border
|
border
|
||||||
borderRadius="3"
|
borderRadius="3"
|
||||||
backgroundColor
|
backgroundColor
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export interface MetricsBarProps extends GridProps {
|
||||||
|
|
||||||
export function MetricsBar({ children, ...props }: MetricsBarProps) {
|
export function MetricsBar({ children, ...props }: MetricsBarProps) {
|
||||||
return (
|
return (
|
||||||
<Grid columns="repeat(auto-fit, minmax(200px, 1fr))" gap {...props}>
|
<Grid columns="repeat(auto-fit, minmax(170px, 1fr))" gap {...props}>
|
||||||
{children}
|
{children}
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Icon, Row, Text } from '@umami/react-zen';
|
import { Icon, Text, Row, Grid } from '@umami/react-zen';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
|
import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
|
||||||
import { Maximize } from '@/components/icons';
|
import { Maximize } from '@/components/icons';
|
||||||
|
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||||
import { percentFilter } from '@/lib/filters';
|
import { percentFilter } from '@/lib/filters';
|
||||||
import { ListTable, ListTableProps } from './ListTable';
|
import { ListTable, ListTableProps } from './ListTable';
|
||||||
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
|
||||||
|
|
||||||
export interface MetricsTableProps extends ListTableProps {
|
export interface MetricsTableProps extends ListTableProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
|
|
@ -14,6 +14,7 @@ export interface MetricsTableProps extends ListTableProps {
|
||||||
dataFilter?: (data: any) => any;
|
dataFilter?: (data: any) => any;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
showMore?: boolean;
|
showMore?: boolean;
|
||||||
|
filterLink?: boolean;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
onDataLoad?: (data: any) => void;
|
onDataLoad?: (data: any) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +25,7 @@ export function MetricsTable({
|
||||||
dataFilter,
|
dataFilter,
|
||||||
limit,
|
limit,
|
||||||
showMore = false,
|
showMore = false,
|
||||||
|
filterLink = true,
|
||||||
params,
|
params,
|
||||||
onDataLoad,
|
onDataLoad,
|
||||||
...props
|
...props
|
||||||
|
|
@ -64,7 +66,7 @@ export function MetricsTable({
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const renderLabel = (row: any) => {
|
const renderLabel = (row: any) => {
|
||||||
return <MetricLabel type={type} data={row} />;
|
return filterLink ? <MetricLabel type={type} data={row} /> : row.label;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -75,21 +77,19 @@ export function MetricsTable({
|
||||||
error={error}
|
error={error}
|
||||||
minHeight="400px"
|
minHeight="400px"
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gridTemplateRows: '1fr auto', minHeight: '400px' }}>
|
<Grid>
|
||||||
<div>{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}</div>
|
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
|
||||||
<div>
|
{showMore && limit && (
|
||||||
{showMore && limit && (
|
<Row justifyContent="center" alignItems="flex-end">
|
||||||
<Row justifyContent="center" alignItems="flex-end">
|
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
<Icon size="sm">
|
||||||
<Icon size="sm">
|
<Maximize />
|
||||||
<Maximize />
|
</Icon>
|
||||||
</Icon>
|
<Text>{formatMessage(labels.more)}</Text>
|
||||||
<Text>{formatMessage(labels.more)}</Text>
|
</LinkButton>
|
||||||
</LinkButton>
|
</Row>
|
||||||
</Row>
|
)}
|
||||||
)}
|
</Grid>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue