-
Notifications
You must be signed in to change notification settings - Fork 41
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 1 commit
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
Some comments aren't visible on the classic Files Changed page.
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,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); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // 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]); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // 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> | ||
| ); | ||
| } | ||
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,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> | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
| } | ||
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,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]); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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> | ||
Dprof-in-tech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </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> | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </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> | ||
| ); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| })} | ||
| {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> | ||
| ); | ||
| } | ||
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,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> | ||
| ); | ||
| } |
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.