412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
Trophy, Users, Hash, Zap, MessageSquare, HandHeart, Flame,
|
|
} from 'lucide-react';
|
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { useTrustedCountryStats } from '@/hooks/useTrustedCountryStats';
|
|
import { useTrustedGlobalStats } from '@/hooks/useTrustedGlobalStats';
|
|
import type {
|
|
StatsTimeframe, TopAction, TopContributor, TopDonor, TopPoster, TrendingHashtag,
|
|
TrustedCountryStats,
|
|
} from '@/lib/statsParser';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const TIMEFRAMES: { value: StatsTimeframe; label: string }[] = [
|
|
{ value: '7d', label: '7d' },
|
|
{ value: '30d', label: '30d' },
|
|
{ value: '90d', label: '90d' },
|
|
{ value: 'all', label: 'All' },
|
|
];
|
|
|
|
interface CommunityStatsPanelProps {
|
|
/** ISO 3166 country code. Omit to render the global (`iso3166:ZZ`) snapshot. */
|
|
countryCode?: string;
|
|
className?: string;
|
|
/**
|
|
* Render at narrow widths (e.g. inside a 360px sidebar): 2-column tile grid
|
|
* with full labels, single-column leaderboards. Defaults to `false` (the
|
|
* roomy multi-column layout used on country pages).
|
|
*/
|
|
compact?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Compact community-stats panel for a country (or the global aggregate when
|
|
* `countryCode` is omitted). Reads pre-computed kind 30385 snapshots — see
|
|
* NIP.md → Kind 30385 for the trust model and tag schema.
|
|
*
|
|
* Renders nothing when no trusted snapshot is available, so it can be safely
|
|
* dropped into any page without producing empty placeholders.
|
|
*/
|
|
export function CommunityStatsPanel({ countryCode, className, compact = false }: CommunityStatsPanelProps) {
|
|
const isGlobal = !countryCode;
|
|
const country = useTrustedCountryStats(isGlobal ? undefined : countryCode);
|
|
const global = useTrustedGlobalStats();
|
|
const { data: stats, isLoading } = isGlobal ? global : country;
|
|
|
|
const [tf, setTf] = useState<StatsTimeframe>('7d');
|
|
|
|
if (isLoading && !stats) return <PanelSkeleton className={className} />;
|
|
if (!stats) return null;
|
|
|
|
return (
|
|
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
|
|
<PanelHeader stats={stats} timeframe={tf} onTimeframeChange={setTf} />
|
|
<AggregateCounts stats={stats} timeframe={tf} compact={compact} />
|
|
<Leaderboards stats={stats} timeframe={tf} compact={compact} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Header ───────────────────────────────────────────────────────────────────
|
|
|
|
function PanelHeader({
|
|
stats, timeframe, onTimeframeChange,
|
|
}: {
|
|
stats: TrustedCountryStats;
|
|
timeframe: StatsTimeframe;
|
|
onTimeframeChange: (tf: StatsTimeframe) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<h2 className="text-sm font-semibold flex items-center gap-2 text-muted-foreground">
|
|
<Trophy className="size-4 text-primary" />
|
|
<span>Community stats</span>
|
|
<span className="text-xs font-normal text-muted-foreground/60">
|
|
updated {formatRelative(stats.updatedAt)}
|
|
</span>
|
|
</h2>
|
|
<Tabs value={timeframe} onValueChange={(v) => onTimeframeChange(v as StatsTimeframe)}>
|
|
<TabsList className="h-7">
|
|
{TIMEFRAMES.map((t) => (
|
|
<TabsTrigger key={t.value} value={t.value} className="h-6 px-2 text-xs">
|
|
{t.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Aggregate counts ─────────────────────────────────────────────────────────
|
|
|
|
function AggregateCounts({
|
|
stats, timeframe, compact,
|
|
}: {
|
|
stats: TrustedCountryStats;
|
|
timeframe: StatsTimeframe;
|
|
compact: boolean;
|
|
}) {
|
|
const c = stats.counts;
|
|
return (
|
|
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-5')}>
|
|
<CountTile icon={MessageSquare} label="Comments" value={c.commentCnt[timeframe]} />
|
|
<CountTile icon={Users} label="Authors" value={c.authorCnt[timeframe]} />
|
|
<CountTile icon={Zap} label="Sats zapped" value={c.zapAmount[timeframe]} />
|
|
<CountTile icon={HandHeart} label="Zaps" value={c.zapCnt[timeframe]} />
|
|
<CountTile icon={Flame} label="Submissions" value={c.submissionCnt[timeframe]} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CountTile({
|
|
icon: Icon, label, value,
|
|
}: {
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
value: number;
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg bg-muted/40 p-2 flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
<Icon className="size-3" />
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
<div className="text-base font-semibold tabular-nums">{formatCompact(value)}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Leaderboards ─────────────────────────────────────────────────────────────
|
|
|
|
function Leaderboards({
|
|
stats, timeframe, compact,
|
|
}: {
|
|
stats: TrustedCountryStats;
|
|
timeframe: StatsTimeframe;
|
|
compact: boolean;
|
|
}) {
|
|
const tfData = stats.byTimeframe[timeframe];
|
|
return (
|
|
<div className={cn('grid gap-4', compact ? 'grid-cols-1' : 'md:grid-cols-2')}>
|
|
<TopActionsList actions={tfData.topActions} />
|
|
<TopPostersList posters={tfData.topPosters} />
|
|
<TopZappedList contributors={tfData.topContributors} />
|
|
<TopDonorsList donors={tfData.topDonors} />
|
|
<TrendingHashtagsList tags={tfData.trendingHashtags} className={compact ? undefined : 'md:col-span-2'} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({ icon: Icon, title }: { icon: React.ComponentType<{ className?: string }>; title: string }) {
|
|
return (
|
|
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
<Icon className="size-3.5 text-primary" />
|
|
<span>{title}</span>
|
|
</h3>
|
|
);
|
|
}
|
|
|
|
function EmptyRow({ label }: { label: string }) {
|
|
return <p className="text-xs text-muted-foreground/60 italic">No {label} yet.</p>;
|
|
}
|
|
|
|
function TopActionsList({ actions }: { actions: TopAction[] }) {
|
|
if (!actions.length) {
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={Trophy} title="Top actions" />
|
|
<EmptyRow label="actions" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={Trophy} title="Top actions" />
|
|
<ul className="space-y-1.5">
|
|
{actions.slice(0, 5).map((a, i) => {
|
|
const parts = a.aTag.split(':');
|
|
const naddrPath = parts.length === 3 ? `/actions` : `/actions`;
|
|
return (
|
|
<li key={a.aTag}>
|
|
<Link
|
|
to={naddrPath}
|
|
className="flex items-start gap-2 rounded-md p-1.5 hover:bg-muted/40 transition-colors"
|
|
>
|
|
<RankBadge rank={i + 1} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{a.title}</div>
|
|
<div className="text-[11px] text-muted-foreground tabular-nums">
|
|
{a.submissions} submissions · {formatCompact(a.zapAmount)} sats zapped
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TopPostersList({ posters }: { posters: TopPoster[] }) {
|
|
if (!posters.length) {
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={MessageSquare} title="Top posters" />
|
|
<EmptyRow label="posters" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={MessageSquare} title="Top posters" />
|
|
<ul className="space-y-1.5">
|
|
{posters.slice(0, 5).map((p, i) => (
|
|
<PubkeyRow
|
|
key={p.pubkey}
|
|
rank={i + 1}
|
|
pubkey={p.pubkey}
|
|
primary={`${p.count} posts`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TopZappedList({ contributors }: { contributors: TopContributor[] }) {
|
|
if (!contributors.length) {
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={Zap} title="Most zapped" />
|
|
<EmptyRow label="zapped contributors" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={Zap} title="Most zapped" />
|
|
<ul className="space-y-1.5">
|
|
{contributors.slice(0, 5).map((c, i) => (
|
|
<PubkeyRow
|
|
key={c.pubkey}
|
|
rank={i + 1}
|
|
pubkey={c.pubkey}
|
|
primary={`${formatCompact(c.totalSats)} sats`}
|
|
secondary={`${c.postCount} posts · avg ${formatCompact(c.avgSats)}`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TopDonorsList({ donors }: { donors: TopDonor[] }) {
|
|
if (!donors.length) {
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={HandHeart} title="Top donors" />
|
|
<EmptyRow label="donors" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<SectionHeader icon={HandHeart} title="Top donors" />
|
|
<ul className="space-y-1.5">
|
|
{donors.slice(0, 5).map((d, i) => (
|
|
<PubkeyRow
|
|
key={d.pubkey}
|
|
rank={i + 1}
|
|
pubkey={d.pubkey}
|
|
primary={`${formatCompact(d.totalSats)} sats`}
|
|
secondary={`${d.zapCount} zaps`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TrendingHashtagsList({ tags, className }: { tags: TrendingHashtag[]; className?: string }) {
|
|
if (!tags.length) {
|
|
return (
|
|
<div className={className}>
|
|
<SectionHeader icon={Hash} title="Trending hashtags" />
|
|
<EmptyRow label="trending hashtags" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={className}>
|
|
<SectionHeader icon={Hash} title="Trending hashtags" />
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{tags.slice(0, 16).map((t) => (
|
|
<Link key={t.tag} to={`/i/${encodeURIComponent(`https://#${t.tag}`)}`}>
|
|
<Badge variant="secondary" className="gap-1 cursor-pointer text-[11px] hover:bg-secondary/80">
|
|
<Hash className="size-3" />
|
|
<span>{t.tag}</span>
|
|
<span className="text-muted-foreground tabular-nums">{t.count}</span>
|
|
</Badge>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Generic pubkey row ───────────────────────────────────────────────────────
|
|
|
|
function PubkeyRow({
|
|
rank, pubkey, primary, secondary,
|
|
}: {
|
|
rank: number;
|
|
pubkey: string;
|
|
primary: string;
|
|
secondary?: string;
|
|
}) {
|
|
const author = useAuthor(pubkey);
|
|
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, pubkey) || genUserName(pubkey);
|
|
const url = useProfileUrl(pubkey, metadata);
|
|
|
|
return (
|
|
<li>
|
|
<Link
|
|
to={url}
|
|
className="flex items-center gap-2 rounded-md p-1.5 hover:bg-muted/40 transition-colors"
|
|
>
|
|
<RankBadge rank={rank} />
|
|
<Avatar className="size-7 shrink-0">
|
|
<AvatarImage src={metadata?.picture} />
|
|
<AvatarFallback className="text-[10px]">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{displayName}</div>
|
|
<div className="text-[11px] text-muted-foreground tabular-nums truncate">
|
|
{primary}
|
|
{secondary && <span className="text-muted-foreground/60"> · {secondary}</span>}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function RankBadge({ rank }: { rank: number }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'shrink-0 size-5 rounded-full bg-primary/15 text-primary text-[10px] font-bold flex items-center justify-center',
|
|
rank === 1 && 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
|
|
rank === 2 && 'bg-zinc-400/20 text-zinc-700 dark:text-zinc-300',
|
|
rank === 3 && 'bg-amber-700/20 text-amber-800 dark:text-amber-400',
|
|
)}
|
|
>
|
|
{rank}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
|
|
|
function PanelSkeleton({ className }: { className?: string }) {
|
|
return (
|
|
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
|
|
<Skeleton className="h-5 w-40" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12 rounded-lg" />
|
|
))}
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-32 rounded-md" />
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
|
|
const compactFormatter = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
|
|
function formatCompact(value: number): string {
|
|
if (!value) return '0';
|
|
return compactFormatter.format(value);
|
|
}
|
|
|
|
function formatRelative(unixSeconds: number): string {
|
|
if (!unixSeconds) return 'never';
|
|
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
|
if (diffSec < 60) return 'just now';
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
const diffDay = Math.floor(diffHr / 24);
|
|
return `${diffDay}d ago`;
|
|
}
|
|
|