330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
import { Link } from "react-router";
|
|
import { t as i18nT } from "i18next";
|
|
import clsx from "clsx";
|
|
import { lazy, Suspense, useEffect, useId, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import UrlUtils from "~/utils/app/UrlUtils";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import type { AnalyticsOverviewDto } from "~/utils/helpers/.server/AnalyticsService";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
import { Card } from "../ui/card";
|
|
import { Label } from "../ui/label";
|
|
|
|
const AnalyticsOverviewChart = lazy(() => import("./AnalyticsOverviewChart"));
|
|
|
|
function joinRootUrl(rootUrl: string | undefined, path: string) {
|
|
if (!rootUrl) {
|
|
return path;
|
|
}
|
|
return `${rootUrl.replace(/\/+$/g, "")}/${path.replace(/^\/+/g, "")}`;
|
|
}
|
|
|
|
const MetricCard = ({ label, value }: { label: string; value: string | number }) => {
|
|
const formatters = useLocaleFormatters();
|
|
|
|
return (
|
|
<Card className="group-hover:bg-secondary mx-auto h-24 gap-3 p-4">
|
|
<Label>{label}</Label>
|
|
<div className="text-2xl font-semibold tracking-normal">{formatters.number(Number(value), { maximumFractionDigits: 0 })}</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const Metrics = ({ authenticated, overview, rootUrl }: { authenticated: Boolean; overview: AnalyticsOverviewDto; rootUrl?: string }) => {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
{authenticated ? (
|
|
<>
|
|
<Link to={joinRootUrl(rootUrl, "visitors")} className="group">
|
|
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
|
|
</Link>
|
|
<Link to={joinRootUrl(rootUrl, "page-views")} className="group">
|
|
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
|
|
</Link>
|
|
<Link to={joinRootUrl(rootUrl, "events")} className="group">
|
|
<MetricCard label={t("analytics.events")} value={overview.events} />
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MetricCard label={t("analytics.uniqueVisitors")} value={overview.uniqueVisitors} />
|
|
<MetricCard label={t("analytics.pageViews")} value={overview.pageViews} />
|
|
<MetricCard label={t("analytics.events")} value={overview.events} />
|
|
</>
|
|
)}
|
|
<div>
|
|
<MetricCard label={t("analytics.liveVisitors")} value={overview.liveVisitors} />
|
|
</div>
|
|
</dl>
|
|
);
|
|
};
|
|
|
|
interface TopItemDto {
|
|
title: string;
|
|
items: { name: string | null; count: number; image?: any }[];
|
|
viewMoreRoute?: string;
|
|
fallbackName?: string;
|
|
bgClassName?: string;
|
|
tabTitle?: string;
|
|
}
|
|
|
|
function TopItemsData({ title, items, viewMoreRoute, fallbackName, bgClassName }: TopItemDto) {
|
|
const { authenticated } = useRootData();
|
|
const formatters = useLocaleFormatters();
|
|
const maxCount = useMemo(() => Math.max(0, ...items.map((item) => item.count)), [items]);
|
|
|
|
function getWidthPercentageCss(current: { count: number }) {
|
|
const percentage = maxCount > 0 ? (current.count / maxCount) * 100 : 0;
|
|
if (percentage >= 95) {
|
|
return "w-[95%]";
|
|
} else if (percentage >= 90) {
|
|
return "w-[90%]";
|
|
} else if (percentage >= 80) {
|
|
return "w-[80%]";
|
|
} else if (percentage >= 70) {
|
|
return "w-[70%]";
|
|
} else if (percentage >= 60) {
|
|
return "w-[60%]";
|
|
} else if (percentage >= 50) {
|
|
return "w-[50%]";
|
|
} else if (percentage >= 40) {
|
|
return "w-[40%]";
|
|
} else if (percentage >= 30) {
|
|
return "w-[30%]";
|
|
} else if (percentage >= 20) {
|
|
return "w-[20%]";
|
|
} else if (percentage >= 10) {
|
|
return "w-[10%]";
|
|
} else if (percentage >= 3) {
|
|
return "w-[3%]";
|
|
}
|
|
return "w-[3%]";
|
|
}
|
|
|
|
const renderTopItems = () => {
|
|
return items.map((item, idx) => (
|
|
<div key={idx} className="flex justify-between space-x-2">
|
|
<div className="w-full truncate">
|
|
<div className="flex items-center space-x-1">
|
|
<div
|
|
className={clsx("flex items-center space-x-2 overflow-visible px-2 py-0.5 text-sm", getWidthPercentageCss(item), bgClassName ?? "bg-orange-50")}
|
|
>
|
|
{item.image !== undefined && <div className="flex-shrink-0">{item.image}</div>}
|
|
|
|
{fallbackName ? <span>{!item.name ? fallbackName : item.name}</span> : <div>{item.name}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-2 py-0.5 text-right text-sm font-extrabold">{formatters.number(item.count, { maximumFractionDigits: 0 })}</div>
|
|
</div>
|
|
));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="h-48 space-y-1 overflow-y-auto">{renderTopItems()}</div>
|
|
{viewMoreRoute && authenticated && (
|
|
<Link to={viewMoreRoute} className="text-muted-foreground hover:text-foreground/80 flex justify-center p-1 text-xs font-medium underline">
|
|
{i18nT("analytics.viewMore")}
|
|
</Link>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TopItems({ tabs }: { tabs: TopItemDto[] }) {
|
|
const [selectedTab, setSelectedTab] = useState(0);
|
|
const tab = tabs[selectedTab] ?? tabs[0];
|
|
const baseId = useId();
|
|
|
|
useEffect(() => {
|
|
if (selectedTab >= tabs.length) {
|
|
setSelectedTab(0);
|
|
}
|
|
}, [selectedTab, tabs.length]);
|
|
|
|
function selectAdjacentTab(offset: number) {
|
|
setSelectedTab((current) => (current + offset + tabs.length) % tabs.length);
|
|
}
|
|
|
|
return (
|
|
<div className="border-border bg-background min-w-0 space-y-1 rounded-md border px-4 py-2 shadow-sm">
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
<h4 id={`${baseId}-heading`} className="min-w-0 truncate text-sm font-bold">
|
|
{tab.title}
|
|
</h4>
|
|
{tabs.length > 1 && (
|
|
<div role="tablist" aria-labelledby={`${baseId}-heading`} className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
|
{tabs.map((item, idx) => {
|
|
const selected = selectedTab === idx;
|
|
|
|
return (
|
|
<button
|
|
key={idx}
|
|
id={`${baseId}-tab-${idx}`}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={selected}
|
|
aria-controls={`${baseId}-panel`}
|
|
tabIndex={selected ? 0 : -1}
|
|
onClick={() => setSelectedTab(idx)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
selectAdjacentTab(1);
|
|
} else if (event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
selectAdjacentTab(-1);
|
|
} else if (event.key === "Home") {
|
|
event.preventDefault();
|
|
setSelectedTab(0);
|
|
} else if (event.key === "End") {
|
|
event.preventDefault();
|
|
setSelectedTab(tabs.length - 1);
|
|
}
|
|
}}
|
|
className={clsx(
|
|
"min-h-10 shrink-0 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
selected ? "text-theme-500 hover:text-theme-600 underline" : "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{item.tabTitle}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div id={`${baseId}-panel`} role={tabs.length > 1 ? "tabpanel" : undefined} aria-labelledby={tabs.length > 1 ? `${baseId}-tab-${selectedTab}` : undefined}>
|
|
<TopItemsData {...tab} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
export default function AnalyticsOverview({ overview, withUsers, rootUrl }: { overview: AnalyticsOverviewDto; withUsers: boolean; rootUrl?: string }) {
|
|
const { authenticated } = useRootData();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="w-64"></div>
|
|
|
|
<Metrics authenticated={authenticated} overview={overview} rootUrl={rootUrl} />
|
|
|
|
{overview.charts && (
|
|
<Suspense fallback={<Card className="h-56 animate-pulse p-4" />}>
|
|
<AnalyticsOverviewChart chartData={overview.charts.pageViewsAndUniqueVisitors} pageViewsLabel={t("analytics.pageViews")} uniqueVisitorsLabel={t("analytics.uniqueVisitors")} />
|
|
</Suspense>
|
|
)}
|
|
|
|
<div className={clsx("grid gap-2", withUsers && "md:grid-cols-2 lg:grid-cols-4", !withUsers && "md:grid-cols-2 lg:grid-cols-3")}>
|
|
<TopItems
|
|
tabs={[
|
|
{
|
|
title: t("analytics.topHttpReferrers"),
|
|
items: overview.top.httpReferrers.map((item) => {
|
|
return {
|
|
image:
|
|
!item.name || item.name === "Direct" ? (
|
|
<svg
|
|
className="text-muted-foreground h-4 w-4"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth="1.5"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
) : item.name.startsWith("localhost") ? (
|
|
<svg
|
|
className="text-muted-foreground h-4 w-4"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth="1.5"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
|
</svg>
|
|
) : (
|
|
<img className="h-4 w-4" src={`http://www.google.com/s2/favicons?domain=${item.name}&sz=256`} alt={item.name} />
|
|
),
|
|
name: UrlUtils.stripTrailingSlash(item.name?.replace("https://", "").replace("http://", "").replace("www.", "") ?? ""),
|
|
count: item.count,
|
|
};
|
|
}),
|
|
fallbackName: t("analytics.direct"),
|
|
bgClassName: "bg-orange-50 dark:bg-orange-900 dark:text-white",
|
|
},
|
|
]}
|
|
/>
|
|
<TopItems
|
|
tabs={[
|
|
{
|
|
title: t("analytics.topPages"),
|
|
items: overview.top.urls,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
|
|
tabTitle: t("analytics.pages"),
|
|
},
|
|
{
|
|
title: t("analytics.topRoutes"),
|
|
items: overview.top.routes,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-emerald-50 dark:bg-emerald-900 dark:text-white",
|
|
tabTitle: t("analytics.routes"),
|
|
},
|
|
]}
|
|
/>
|
|
<TopItems
|
|
tabs={[
|
|
{
|
|
tabTitle: t("analytics.source"),
|
|
title: t("analytics.topSources"),
|
|
items: overview.top.sources,
|
|
fallbackName: t("analytics.fallbacks.noReferrer"),
|
|
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
|
|
},
|
|
{
|
|
tabTitle: t("analytics.referrers"),
|
|
title: t("analytics.topReferrers"),
|
|
items: overview.top.via,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-teal-50 dark:bg-teal-900 dark:text-white",
|
|
},
|
|
{
|
|
tabTitle: t("analytics.medium"),
|
|
title: t("analytics.medium"),
|
|
items: overview.top.medium,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
|
|
},
|
|
{
|
|
tabTitle: t("analytics.campaigns"),
|
|
title: t("analytics.campaigns"),
|
|
items: overview.top.campaign,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
|
|
},
|
|
]}
|
|
/>
|
|
{withUsers && (
|
|
<TopItems
|
|
tabs={[
|
|
{
|
|
tabTitle: t("models.user.plural"),
|
|
title: t("models.user.plural"),
|
|
items: overview.top.user,
|
|
fallbackName: t("shared.notSet"),
|
|
bgClassName: "bg-indigo-50 dark:bg-indigo-900 dark:text-white",
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|