SkateSpots/src/pages/PyramidPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

203 lines
8.5 KiB
TypeScript

import { useSeoMeta } from '@unhead/react';
import { useMemo } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTrustScore } from '@/hooks/useTrustScore';
import { genUserName } from '@/lib/genUserName';
import { Trophy, Medal } from 'lucide-react';
import { cn } from '@/lib/utils';
function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots: number; events: number; rank: number }) {
const author = useAuthor(pubkey);
const { data: trustScore } = useTrustScore(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const total = spots + events;
const rankColor = rank === 1 ? 'text-yellow-400' : rank === 2 ? 'text-gray-300' : rank === 3 ? 'text-orange-400' : 'text-gray-500';
const rankEmoji = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
// Trust score color coding
const trustScoreColor = trustScore === undefined ? 'text-gray-500' :
trustScore >= 80 ? 'text-green-500' :
trustScore >= 50 ? 'text-yellow-500' :
'text-gray-500';
return (
<div className="flex items-center gap-4 p-4 bg-black/50 rounded-lg border border-gray-700 hover:border-[#ff0040]/50 transition-all">
<div className={`text-3xl font-black ${rankColor} w-12 text-center`}>
{rankEmoji || `#${rank}`}
</div>
<Avatar className="h-12 w-12 border-2 border-[#ff0040]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-black text-[#ff0040] font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="font-bold text-white">{displayName}</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00]">{metadata.nip05}</span>
</div>
)}
</div>
<div className="flex gap-4 text-sm">
<div className="text-center">
<div className="text-[#ff0040] font-black">{spots}</div>
<div className="text-gray-500 text-xs uppercase">Spots</div>
</div>
<div className="text-center">
<div className="text-[#ccff00] font-black">{events}</div>
<div className="text-gray-500 text-xs uppercase">Events</div>
</div>
<div className="text-center">
<div className="text-[#ff6600] font-black">{total}</div>
<div className="text-gray-500 text-xs uppercase">Total</div>
</div>
<div className="text-center min-w-[48px]">
<div className={cn("font-black", trustScoreColor)}>
{trustScore ?? '—'}
</div>
<div className="text-gray-500 text-xs uppercase">Trust</div>
</div>
</div>
</div>
);
}
export default function PyramidPage() {
useSeoMeta({
title: 'Pyramid Rankings - SkateSpots',
description: 'Top contributors and community leaders.',
});
const { user } = useCurrentUser();
const { data: allSpots } = useSkateSpots(500);
const { data: allEvents } = useSkateEvents(500);
const { data: userTrustScore } = useTrustScore(user?.pubkey);
// Calculate contributor rankings
const rankings = useMemo(() => {
if (!allSpots && !allEvents) return [];
const contributorMap = new Map<string, { spots: number; events: number }>();
// Count spots per contributor
allSpots?.forEach(spot => {
const current = contributorMap.get(spot.pubkey) || { spots: 0, events: 0 };
contributorMap.set(spot.pubkey, { ...current, spots: current.spots + 1 });
});
// Count events per contributor
allEvents?.forEach(event => {
const current = contributorMap.get(event.pubkey) || { spots: 0, events: 0 };
contributorMap.set(event.pubkey, { ...current, events: current.events + 1 });
});
// Convert to array and sort by total contributions
return Array.from(contributorMap.entries())
.map(([pubkey, stats]) => ({
pubkey,
spots: stats.spots,
events: stats.events,
total: stats.spots + stats.events,
}))
.sort((a, b) => b.total - a.total);
}, [allSpots, allEvents]);
const userRank = rankings.findIndex(r => r.pubkey === user?.pubkey) + 1;
const userStats = rankings.find(r => r.pubkey === user?.pubkey);
return (
<>
<div className="container px-4 sm:px-6 py-8 sm:py-12">
<div className="text-center mb-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-orange-500" />
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4 neon-header">
<span className="neon-red">PYRAMID</span> <span className="neon-lime">RANKINGS</span>
</h1>
<p className="text-gray-400 text-xl">Top contributors in your city</p>
</div>
{/* Your Stats */}
{user && userStats && (
<Card className="bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20 border-2 border-[#ff0040]/50 max-w-4xl mx-auto mb-8">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-4xl font-black text-[#ff0040]">#{userRank}</div>
<div>
<div className="font-black text-xl text-white uppercase">Your Rank</div>
<div className="text-sm text-gray-400">Keep contributing to climb higher!</div>
</div>
</div>
<div className="flex gap-4">
<div className="text-center">
<div className="text-3xl font-black text-[#ff0040]">{userStats.spots}</div>
<div className="text-xs text-gray-400 uppercase">Spots</div>
</div>
<div className="text-center">
<div className="text-3xl font-black text-[#ccff00]">{userStats.events}</div>
<div className="text-xs text-gray-400 uppercase">Events</div>
</div>
<div className="text-center">
<div className="text-3xl font-black text-[#ff6600]">{userStats.total}</div>
<div className="text-xs text-gray-400 uppercase">Total</div>
</div>
<div className="text-center">
<div className={cn(
"text-3xl font-black",
userTrustScore === undefined ? "text-gray-500" :
userTrustScore >= 80 ? "text-green-500" :
userTrustScore >= 50 ? "text-yellow-500" :
"text-gray-500"
)}>
{userTrustScore ?? '—'}
</div>
<div className="text-xs text-gray-400 uppercase">Trust</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<Card className="bg-black/90 border-2 border-gray-700 max-w-4xl mx-auto">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-6">
<Medal className="h-8 w-8 text-[#ff0040]" />
<h2 className="text-2xl font-black uppercase">Top Contributors</h2>
</div>
{rankings.length > 0 ? (
<div className="space-y-2">
{rankings.slice(0, 20).map((contributor, idx) => (
<ContributorRow
key={contributor.pubkey}
pubkey={contributor.pubkey}
spots={contributor.spots}
events={contributor.events}
rank={idx + 1}
/>
))}
</div>
) : (
<div className="text-center py-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400">No contributors yet. Be the first to add a spot or create an event!</p>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}