diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.module.css b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.module.css
deleted file mode 100644
index e55063c3..00000000
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.module.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.row {
- display: flex;
- align-items: center;
- gap: 10px;
-}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
index 2c16bced..c4ee9c02 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
@@ -1,8 +1,7 @@
import { useCallback } from 'react';
+import { IconLabel } from '@umami/react-zen';
import { ListTable } from '@/components/metrics/ListTable';
import { useLocale, useCountryNames, useMessages } from '@/components/hooks';
-import classNames from 'classnames';
-import styles from './RealtimeCountries.module.css';
import { TypeIcon } from '@/components/common/TypeIcon';
export function RealtimeCountries({ data }) {
@@ -12,10 +11,7 @@ export function RealtimeCountries({ data }) {
const renderCountryName = useCallback(
({ label: code }) => (
-
-
- {countryNames[code]}
-
+ } label={countryNames[code]} />
),
[countryNames, locale],
);
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.module.css b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.module.css
deleted file mode 100644
index f87d86e8..00000000
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.module.css
+++ /dev/null
@@ -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);
- }
-}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
index 7f4df048..cdd67e7a 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
@@ -1,9 +1,8 @@
import { MetricCard } from '@/components/metrics/MetricCard';
import { useMessages } from '@/components/hooks';
-import { RealtimeData } from '@/lib/types';
import { MetricsBar } from '@/components/metrics/MetricsBar';
-export function RealtimeHeader({ data }: { data: RealtimeData }) {
+export function RealtimeHeader({ data }: { data: any }) {
const { formatMessage, labels } = useMessages();
const { totals }: any = data || {};
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css
deleted file mode 100644
index 3703baa3..00000000
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css
+++ /dev/null
@@ -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%;
- }
-}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
index 8cd31016..9ae19bf8 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
@@ -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 { Empty } from '@/components/common/Empty';
import { FilterButtons } from '@/components/input/FilterButtons';
@@ -5,17 +9,15 @@ import {
useCountryNames,
useLocale,
useMessages,
+ useNavigation,
useTimezone,
useWebsite,
} from '@/components/hooks';
import { Eye, User } from '@/components/icons';
import { Lightning } from '@/components/svg';
import { BROWSERS, OS_NAMES } from '@/lib/constants';
-import { stringToColor } from '@/lib/format';
-import { useMemo, useState } from 'react';
-import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
-import { FixedSizeList } from 'react-window';
-import styles from './RealtimeLog.module.css';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { Avatar } from '@/components/common/Avatar';
const TYPE_ALL = 'all';
const TYPE_PAGEVIEW = 'pageview';
@@ -37,6 +39,7 @@ export function RealtimeLog({ data }: { data: any }) {
const { formatTimezoneDate } = useTimezone();
const { countryNames } = useCountryNames(locale);
const [filter, setFilter] = useState(TYPE_ALL);
+ const { updateParams } = useNavigation();
const buttons = [
{
@@ -59,8 +62,6 @@ export function RealtimeLog({ data }: { data: any }) {
const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
- const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
-
const getIcon = ({ __type }) => icons[__type];
const getDetail = (log: {
@@ -84,7 +85,6 @@ export function RealtimeLog({ data }: { data: any }) {
@@ -98,12 +98,7 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) {
return (
-
+
{urlPath}
);
@@ -124,19 +119,18 @@ export function RealtimeLog({ data }: { data: any }) {
}
};
- const Row = ({ index, style }) => {
+ const TableRow = ({ index, style }) => {
const row = logs[index];
return (
-
-
-
-
-
{getTime(row)}
-
-
{getIcon(row)}
+
+
+
+
+ {getTime(row)}
+
{getDetail(row)}
-
-
+
+
);
};
@@ -172,20 +166,21 @@ export function RealtimeLog({ data }: { data: any }) {
}, [data, filter, formatValue, search]);
return (
-
-
-
+
+ {formatMessage(labels.activity)}
+
+
-
-
{formatMessage(labels.activity)}
-
+
+
{logs?.length === 0 && }
{logs?.length > 0 && (
- {Row}
+ {TableRow}
)}
-
-
+
+
+
);
}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
index e0ffd00c..7f9ab608 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
@@ -9,7 +9,8 @@ import { WorldMap } from '@/components/metrics/WorldMap';
import { useRealtimeQuery } from '@/components/hooks';
import { RealtimeLog } from './RealtimeLog';
import { RealtimeHeader } from './RealtimeHeader';
-import { RealtimeUrls } from './RealtimeUrls';
+import { RealtimePaths } from './RealtimePaths';
+import { RealtimeReferrers } from './RealtimeReferrers';
import { RealtimeCountries } from './RealtimeCountries';
import { percentFilter } from '@/lib/filters';
@@ -32,12 +33,15 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
+
+
+
-
+
-
+
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
new file mode 100644
index 00000000..91c6b4d8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
@@ -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 (
+
+ {x}
+
+ );
+ };
+
+ const pages = percentFilter(
+ Object.keys(urls)
+ .map(key => {
+ return {
+ x: key,
+ y: urls[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
new file mode 100644
index 00000000..85326cd0
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
@@ -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 (
+
+ {x}
+
+ );
+ };
+
+ const domains = percentFilter(
+ Object.keys(referrers)
+ .map(key => {
+ return {
+ x: key,
+ y: referrers[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx
deleted file mode 100644
index 5bb51c92..00000000
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeUrls.tsx
+++ /dev/null
@@ -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 (
-
- {x}
-
- );
- };
-
- 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 (
- <>
-
-
-
- {filter === FILTER_REFERRERS && (
- ({
- label: x,
- count: y,
- percent: z,
- }))}
- />
- )}
- {filter === FILTER_PAGES && (
- ({
- label: x,
- count: y,
- percent: z,
- }))}
- />
- )}
- >
- );
-}
diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx
index 74c3d7ae..f8ed7c94 100644
--- a/src/components/common/Avatar.tsx
+++ b/src/components/common/Avatar.tsx
@@ -1,4 +1,3 @@
-'use client';
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { lorelei } from '@dicebear/collection';
@@ -6,41 +5,17 @@ import { getColor, getPastel } from '@/lib/colors';
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 }) {
const backgroundColor = getPastel(getColor(seed), 4);
const avatar = useMemo(() => {
- const svg = createAvatar(lib, {
+ return createAvatar(lib, {
...props,
seed,
size,
backgroundColor: [backgroundColor],
- }).toString();
-
- const base64 = toBase64(svg);
- return `data:image/svg+xml;base64,${base64}`;
- }, [seed, size, backgroundColor, props]);
+ }).toDataUri();
+ }, []);
return
;
}