Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import { useLeaderboard } from "@/hooks/use-leaderboard";
import { LeaderboardTable } from "@/components/leaderboard/leaderboard-table";
import { LeaderboardFilters } from "@/components/leaderboard/leaderboard-filters";
import { UserRankSidebar } from "@/components/leaderboard/user-rank-sidebar";
import { LeaderboardFilters as FiltersType, ReputationTier } from "@/types/leaderboard";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";

export default function LeaderboardPage() {
const router = useRouter();
const searchParams = useSearchParams();

// Initialize filters from URL
const initialTimeframe = (searchParams.get("timeframe") as FiltersType["timeframe"]) || "ALL_TIME";
const initialTier = (searchParams.get("tier") as ReputationTier) || undefined;

const [filters, setFilters] = useState<FiltersType>({
timeframe: initialTimeframe,
tier: initialTier,
tags: [],
});

// Fake current user ID for demo purposes
// In a real app this would come from auth context
const currentUserId = "user-1";

const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useLeaderboard(filters, 20);

// Sync filters to URL
useEffect(() => {
const params = new URLSearchParams();
if (filters.timeframe !== "ALL_TIME") params.set("timeframe", filters.timeframe);
if (filters.tier) params.set("tier", filters.tier);

router.replace(`/leaderboard?${params.toString()}`, { scroll: false });
}, [filters, router]);

// Flatten infinite query data
const entries = data?.pages.flatMap((page) => page.entries) || [];

return (
<div className="min-h-screen bg-background pb-12">
{/* Hero Header */}
<div className="border-b border-border/40">
<div className="container mx-auto px-4 py-12">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight mb-3">
Leaderboard
</h1>
<p className="text-lg text-white max-w-2xl">
Recognizing the top contributors in the ecosystem.
</p>
</div>
</div>

<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main Content - Table */}
<div className="lg:col-span-3 space-y-6">
<LeaderboardFilters
filters={filters}
onFilterChange={setFilters}
/>

<LeaderboardTable
entries={entries}
isLoading={isLoading}
hasNextPage={hasNextPage || false}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
currentUserId={currentUserId}
/>
</div>

{/* Sidebar - User Rank */}
<div className="lg:col-span-1">
<UserRankSidebar userId={currentUserId} />
</div>
</div>
</div>
</div>
);
}
103 changes: 103 additions & 0 deletions components/leaderboard/leaderboard-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
LeaderboardFilters as FiltersType,
LeaderboardTimeframe,
ReputationTier
} from "@/types/leaderboard";
import { FilterX } from "lucide-react";

interface LeaderboardFiltersProps {
filters: FiltersType;
onFilterChange: (filters: FiltersType) => void;
}

const TIMEFRAMES: { value: LeaderboardTimeframe; label: string }[] = [
{ value: "ALL_TIME", label: "All Time" },
{ value: "THIS_MONTH", label: "This Month" },
{ value: "THIS_WEEK", label: "This Week" },
];

const TIERS: { value: ReputationTier; label: string }[] = [
{ value: "LEGEND", label: "Legend" },
{ value: "EXPERT", label: "Expert" },
{ value: "ESTABLISHED", label: "Established" },
{ value: "CONTRIBUTOR", label: "Contributor" },
{ value: "NEWCOMER", label: "Newcomer" },
];

export function LeaderboardFilters({ filters, onFilterChange }: LeaderboardFiltersProps) {
const updateFilter = (key: keyof FiltersType, value: unknown) => {
onFilterChange({ ...filters, [key]: value });
};

const clearFilters = () => {
onFilterChange({
timeframe: "ALL_TIME",
tier: undefined,
tags: [],
});
};

const hasActiveFilters = filters.timeframe !== "ALL_TIME" || filters.tier || (filters.tags?.length || 0) > 0;

return (
<div className="flex flex-wrap items-center gap-3 text-white">
{/* Timeframe Select */}
<Select
value={filters.timeframe}
onValueChange={(val) => updateFilter("timeframe", val as LeaderboardTimeframe)}
>
<SelectTrigger className="w-[140px] bg-background border-border/50">
<SelectValue placeholder="Timeframe" />
</SelectTrigger>
<SelectContent>
{TIMEFRAMES.map((tf) => (
<SelectItem key={tf.value} value={tf.value}>
{tf.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Tier Select */}
<Select
value={filters.tier || "all"}
onValueChange={(val) => updateFilter("tier", val === "all" ? undefined : (val as ReputationTier))}
>
<SelectTrigger className="w-[140px] bg-background-card border-border/50">
<SelectValue placeholder="All Tiers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tiers</SelectItem>
{TIERS.map((tier) => (
<SelectItem key={tier.value} value={tier.value}>
{tier.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Clear Button */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-muted-foreground hover:text-foreground h-9 px-2.5"
>
<FilterX className="mr-2 h-4 w-4" />
Clear
</Button>
)}
</div>
);
}
164 changes: 164 additions & 0 deletions components/leaderboard/leaderboard-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use client";
import React, { useEffect, useRef } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { LeaderboardEntry } from "@/types/leaderboard";
import { cn } from "@/lib/utils";
import { RankBadge } from "./rank-badge";
import { TierBadge } from "./tier-badge";
import { StreakIndicator } from "./streak-indicator";

interface LeaderboardTableProps {
entries: LeaderboardEntry[];
isLoading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
currentUserId?: string;
}

export function LeaderboardTable({
entries,
isLoading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
currentUserId,
}: LeaderboardTableProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
onLoadMore();
}
},
{ threshold: 0.1 }
);

if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}

return () => {
observer.disconnect();
};
}, [hasNextPage, onLoadMore]);

if (isLoading && entries.length === 0) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}

return (
<div className="rounded-md border border-border/50 overflow-hidden bg-background-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border">
<TableHead className="w-[80px] text-center font-bold text-white">RANK</TableHead>
<TableHead className="font-bold text-white">CONTRIBUTOR</TableHead>
<TableHead className="hidden md:table-cell font-bold text-white">TIER</TableHead>
<TableHead className="text-right font-bold text-white">SCORE</TableHead>
<TableHead className="text-right hidden sm:table-cell font-bold text-white">COMPLETED</TableHead>
<TableHead className="text-right hidden lg:table-cell font-bold text-white">EARNINGS</TableHead>
<TableHead className="text-right font-bold text-white">STREAK</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => {
const isCurrentUser = currentUserId === entry.contributor.userId;

return (
<TableRow
key={entry.contributor.id}
className={cn(
"border-b border-border/60 hover:bg-muted/20",
isCurrentUser && "bg-secondary/40"
)}
>
<TableCell className="text-center font-medium">
<div className="flex justify-center">
<RankBadge rank={entry.rank} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 border border-border/60">
<AvatarImage src={entry.contributor.avatarUrl || undefined} />
<AvatarFallback className="bg-secondary text-secondary-foreground">{entry.contributor.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className={cn("font-semibold text-white", isCurrentUser && "text-primary")}>
{entry.contributor.displayName}
{isCurrentUser && " (You)"}
</span>
<div className="flex gap-1 md:hidden">
<span className="text-xs text-muted-foreground">{entry.contributor.tier}</span>
</div>
{/* Mobile tags */}
<div className="flex gap-1 mt-1 md:hidden">
{entry.contributor.topTags.slice(0, 1).map(tag => (
<span key={tag} className="text-[10px] bg-muted px-1 rounded">{tag}</span>
))}
</div>
</div>
</div>
{/* Desktop tags */}
<div className="hidden md:flex gap-1 mt-2">
{entry.contributor.topTags.map(tag => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1 h-5 font-normal">
{tag}
</Badge>
))}
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-white">
<TierBadge tier={entry.contributor.tier} />
</TableCell>
<TableCell className="text-right font-mono text-white font-medium">
{entry.contributor.totalScore.toLocaleString()}
</TableCell>
<TableCell className="text-right hidden sm:table-cell text-white">
{entry.contributor.stats.totalCompleted}
</TableCell>
<TableCell className="text-right hidden lg:table-cell font-mono text-white">
${entry.contributor.stats.totalEarnings.toLocaleString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
<StreakIndicator streak={entry.contributor.stats.currentStreak} />
</div>
</TableCell>
</TableRow>
);
})}
{isFetchingNextPage && (
<TableRow>
<TableCell colSpan={7} className="text-center py-4">
<div className="flex items-center justify-center text-white/70 text-sm">
Loading more...
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{hasNextPage && <div ref={loadMoreRef} className="h-4" />}
</div>
);
}
22 changes: 22 additions & 0 deletions components/leaderboard/rank-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";

interface RankBadgeProps {
rank: number;
className?: string;
}

export function RankBadge({ rank, className }: RankBadgeProps) {
if (rank <= 3) {
return (
<div className={cn("flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-bold", className)}>
{rank}
</div>
);
}

return (
<div className="flex items-center justify-center w-6 h-6 text-white font-semibold text-sm">
{rank}
</div>
);
}
Loading
Loading