Files
eranos/src/components/CommunityStatsPanel.tsx
T
Chad Curtis 2d260b37b5 Replace the world page bottom drawer with a floating discovery launcher
The persistent vaul bottom sheet ate half the map on phones and tablets
and made the docked discovery panel crush the map between 900px and
1280px. Swap it for a centered Dialog modal opened by a single button
anchored top-right next to Leaflet's zoom controls.

The docked WorldDiscoveryPanel now hides below xl (1280px) instead of
the sidebar breakpoint (900px) so the map stays usable when the panel
would otherwise crowd it. CommunityStatsPanel drops its rounded-2xl
card border in compact mode so it doesn't render box-in-a-box inside
the modal or docked panel.

Initial map zoom bumps from 2 to 3 below xl so phones and tablets
don't see ocean bands above and below the world tiles. Default
viewport center moves to Venezuela. Leaflet zoom controls picked up
themed background / foreground / border / primary-on-hover styling to
match the rest of the UI, and the country search header is opaque so
it no longer renders blurry inside the modal.
2026-05-15 19:37:23 -07:00

430 lines
15 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} compact={compact} />;
if (!stats) return null;
return (
<section
className={cn(
// Standalone usage gets a card-style border. Compact usage is
// embedded inside another bordered surface (the world discovery
// modal / docked panel) where an extra border produces a
// box-in-a-box look — drop it and rely on spacing alone.
compact
? 'space-y-4'
: '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, compact }: { className?: string; compact?: boolean }) {
return (
<section
className={cn(
compact
? 'space-y-4'
: '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`;
}