Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
135 changes: 135 additions & 0 deletions app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"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";
import { TIMEFRAMES, TIERS } from "@/components/leaderboard/leaderboard-filters";
import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";

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

// Validate and initialize filters from URL
const rawTimeframe = searchParams.get("timeframe");
const rawTier = searchParams.get("tier");

const initialTimeframe = TIMEFRAMES.some(t => t.value === rawTimeframe)
? (rawTimeframe as FiltersType["timeframe"])
: "ALL_TIME";

const initialTier = TIERS.some(t => t.value === rawTier)
? (rawTier as ReputationTier)
: undefined;

const initialTags = searchParams.get("tags") ? searchParams.get("tags")?.split(",") : [];

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

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

// Debounce filters to prevent rapid API calls/URL updates
const [debouncedFilters, setDebouncedFilters] = useState<FiltersType>(filters);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedFilters(filters);
}, 400);

return () => clearTimeout(timer);
}, [filters]);

const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch
} = useLeaderboard(debouncedFilters, 20);

// Sync debounced filters to URL
useEffect(() => {
const params = new URLSearchParams();
if (debouncedFilters.timeframe !== "ALL_TIME") params.set("timeframe", debouncedFilters.timeframe);
if (debouncedFilters.tier) params.set("tier", debouncedFilters.tier);
if (debouncedFilters.tags && debouncedFilters.tags.length > 0) {
params.set("tags", debouncedFilters.tags.join(","));
}

router.replace(`/leaderboard?${params.toString()}`, { scroll: false });
}, [debouncedFilters, 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/70 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}
/>

{isError ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<p>Failed to load leaderboard data. {(error as Error)?.message}</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="w-fit bg-background text-foreground border-border hover:bg-muted">
Try Again
</Button>
</AlertDescription>
</Alert>
) : (
<LeaderboardTable
entries={entries}
isLoading={isLoading}
hasNextPage={hasNextPage || false}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
currentUserId={currentUserId}
onRowClick={(entry) => router.push(`/user/${entry.contributor.userId}`)}
/>
)}
</div>

{/* Sidebar - User Rank */}
<div className="lg:col-span-1">
<UserRankSidebar userId={currentUserId} />
</div>
</div>
</div>
</div>
);
}
212 changes: 212 additions & 0 deletions components/leaderboard/leaderboard-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"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";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Check, PlusCircle } from "lucide-react";
import { cn } from "@/lib/utils";

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

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

export 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" },
];

// Mock available tags for filter - in real app could be passed as prop
const AVAILABLE_TAGS = [
"Auditing", "Smart Contracts", "DeFi", "Frontend", "Backend",
"Design", "Documentation", "Testing", "Security", "Zero Knowledge"
];

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

const handleTagToggle = (tag: string) => {
const currentTags = filters.tags || [];
const newTags = currentTags.includes(tag)
? currentTags.filter((t) => t !== tag)
: [...currentTags, tag];
updateFilter("tags", newTags);
};

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-card 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>

{/* Tags Multi-Select */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-10 border-border/50 border-dashed">
<PlusCircle className="mr-2 h-4 w-4" />
Tags
{(filters.tags?.length || 0) > 0 && (
<>
<div className="hidden space-x-1 lg:flex ml-2">
{(filters.tags?.length ?? 0) > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{filters?.tags?.length} selected
</Badge>
) : (
filters.tags?.map((tag) => (
<Badge
variant="secondary"
key={tag}
className="rounded-sm px-1 font-normal"
>
{tag}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Tags..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{AVAILABLE_TAGS.map((tag) => {
const isSelected = filters.tags?.includes(tag);
return (
<CommandItem
key={tag}
onSelect={() => handleTagToggle(tag)}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<Check className={cn("h-4 w-4")} />
</div>
<span>{tag}</span>
</CommandItem>
);
})}
</CommandGroup>
{(filters.tags?.length || 0) > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => updateFilter("tags", [])}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>

{/* 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>
);
}
Loading
Loading