-
Notifications
You must be signed in to change notification settings - Fork 19
feat: introduce leaderboard page with filtering, table, and user rank #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
0xdevcollins
merged 3 commits into
boundlessfi:main
from
Dprof-in-tech:feat-implement-the-leaderboard-page
Jan 30, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
0754108
feat: introduce leaderboard page with filtering, table, and user rank…
Dprof-in-tech e5f7418
feat: Implement tag filtering, debounced filter updates, and enhanced…
Dprof-in-tech 4c19af0
feat: Add progress to next tier display in user rank sidebar and upda…
Dprof-in-tech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.