feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top pages, top referrers) and a per-port admin config UI for the Umami URL / website-ID / API token. Settings live in system_settings keyed per-port so a future second port has its own Umami account. Adds a website glance tile to the main dashboard, a server-side test-credentials endpoint, and a stable cache key for the active- visitor poll so React Query doesn't fragment the cache per range. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
66
src/components/website-analytics/top-list.tsx
Normal file
66
src/components/website-analytics/top-list.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
rows: UmamiMetricRow[] | null;
|
||||
loading: boolean;
|
||||
/** Label substituted when `x` is empty (e.g. direct traffic referrers). */
|
||||
defaultLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "top N" list used for top pages / referrers / countries.
|
||||
* Renders each row as label + numeric count, with a thin progress bar
|
||||
* scaled to the largest count in the set so the visual density tells
|
||||
* the same story at a glance as the numbers.
|
||||
*/
|
||||
export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
<Skeleton className="h-4 w-3/6" />
|
||||
</div>
|
||||
) : !rows || rows.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">No data</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.slice(0, 10).map((row, i) => {
|
||||
const max = rows[0]?.y ?? 1;
|
||||
const pct = (row.y / max) * 100;
|
||||
const label = row.x?.trim() || defaultLabel;
|
||||
return (
|
||||
<li key={`${row.x}-${i}`} className="text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-medium">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{row.y.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-brand"
|
||||
style={{ width: `${Math.max(2, pct)}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user