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>
67 lines
2.3 KiB
TypeScript
67 lines
2.3 KiB
TypeScript
'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>
|
|
);
|
|
}
|