Files
homarr/packages/widgets/src/umami/umami-content.tsx
T

280 lines
9.0 KiB
TypeScript

"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
interface UmamiContentProps {
integrationIds: string[];
websiteId: string;
timeFrame: TimeFrame;
eventName?: string;
eventNames: string[];
chartStyle: string;
chartType: string;
viewMode: string;
topCount: number;
width: number;
height: number;
displayMode: "compact" | "advanced";
}
export function UmamiContent({
integrationIds,
websiteId,
timeFrame,
eventName,
eventNames,
chartStyle,
chartType,
viewMode,
topCount,
width,
height,
displayMode,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const tCommon = useScopedI18n("common");
const locale = useCurrentIntlLocale();
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: results = [], isPending: isStatsPending } = clientApi.widget.umami.getVisitorStats.useQuery(
{ integrationIds, websiteId, timeFrame, eventName },
umamiQueryOptions,
);
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(
activeVisitorsInput,
umamiQueryOptions,
);
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
: undefined;
const firstResult = results[0];
if (isStatsPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (!firstResult) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("error.noData")}
</Text>
</Stack>
);
}
const { visitorStats } = firstResult;
const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({
label: formatXLabel(point.timestamp, timeFrame),
visitors: point.visitors,
...(point.events !== undefined ? { events: point.events } : {}),
}));
const hasEventSeries = visitorStats.dataPoints.some(({ events }) => events !== undefined);
const series = [
{ name: "visitors", color: "blue.5" },
...(hasEventSeries ? [{ name: "events", color: "orange.5" }] : []),
];
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && height < 120;
const showXAxis = isAdvanced || height >= 140;
const showSecondaryStats = isAdvanced || height >= 96;
const showDetailedStats = isAdvanced || (width >= 260 && height >= 150);
const selectedView =
viewMode === "events" ? (
<UmamiEventsContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
eventNames={eventNames}
chartType={chartType}
showXAxis={showXAxis}
/>
) : viewMode === "topPages" ? (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : viewMode === "topReferrers" ? (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : chartType === "sparkline" ? (
<LineChart
h="100%"
data={chartData}
dataKey="label"
series={series}
withDots={false}
curveType="monotone"
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis={showXAxis}
withYAxis={false}
xAxisProps={{ tick: { fontSize: 9, fill: tickColor }, interval: "preserveStartEnd" }}
/>
) : (
<BarChart
h="100%"
data={chartData}
dataKey="label"
series={series}
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis={showXAxis}
withYAxis={false}
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
xAxisProps={{ tick: { fontSize: 9, fill: tickColor }, interval: "preserveStartEnd" }}
/>
);
const advancedLists = (
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
);
const advancedContent =
width < 900 ? (
<ScrollArea h="100%">
<Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists}
</Stack>
</ScrollArea>
) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}>
{selectedView}
</Box>
<ScrollArea h="100%">{advancedLists}</ScrollArea>
</SimpleGrid>
);
return (
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
</Text>
{(eventName && viewMode === "chart" && visitorStats.eventCount !== undefined) ||
(viewMode === "events" && multiEventTotal !== undefined) ? (
<Group gap={6} align="baseline" wrap="nowrap">
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{viewMode === "events"
? (multiEventTotal ?? 0).toLocaleString(locale)
: (visitorStats.eventCount ?? 0).toLocaleString(locale)}
</Text>
<Text size="xs" c="dimmed">
{t("event.label")}
</Text>
</Group>
<Text size="xs" c="dimmed">
/
</Text>
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString(locale)}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
</Group>
) : (
<Group gap={4} align="baseline">
<Text size="xl" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString(locale)}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
)}
</Group>
{showSecondaryStats && (
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString(locale)} {t("active")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString(locale)} {t("pageviews")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalVisits.toLocaleString(locale)} {t("visits")}
</Text>
{showDetailedStats && (
<>
<Text size="xs" c="dimmed">
{visitorStats.bounceRate.toLocaleString(locale)}% {t("bounceRate")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
</Text>
</>
)}
</Group>
)}
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
</Stack>
);
}