Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-router": "1.97.0",
"@tanstack/react-table": "^8.21.3",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
419 changes: 45 additions & 374 deletions apps/app/src/components/Leaderboard.tsx

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions apps/app/src/components/leaderboard/LeaderboardColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { ChevronUp } from "lucide-react";
import { createColumnHelper } from "@tanstack/react-table";
import { LeaderboardEntry } from "../../lib/api";
import { UserLink } from "../FeedItem";

export interface ExtendedLeaderboardEntry extends LeaderboardEntry {
originalRank: number;
}

export function createLeaderboardColumns(
expandedRows: number[],
toggleRow: (index: number) => void,
) {
const columnHelper = createColumnHelper<ExtendedLeaderboardEntry>();

return [
columnHelper.accessor("originalRank", {
header: "Rank",
cell: (info) => {
const rank = info.getValue();
return (
<div className="flex items-center w-[35px] h-[32px]">
{rank === 1 && (
<img
src="/icons/star-gold.svg"
className="h-4 w-4 mr-1"
alt="Gold star - 1st place"
/>
)}
{rank === 2 && (
<img
src="/icons/star-silver.svg"
className="h-4 w-4 mr-1"
alt="Silver star - 2nd place"
/>
)}
{rank === 3 && (
<img
src="/icons/star-bronze.svg"
className="h-4 w-4 mr-1"
alt="Bronze star - 3rd place"
/>
)}
<div className="flex w-full text-right justify-end">
<span className="text-[#111111] font-medium">{rank}</span>
</div>
</div>
);
},
}),
columnHelper.accessor("curatorUsername", {
header: "Username",
cell: (info) => (
<div className="flex items-center gap-2 h-[32px]">
<UserLink username={info.getValue()} />
</div>
),
}),
columnHelper.accessor(
(row) => {
return row.submissionCount > 0
? Math.round((row.approvalCount / row.submissionCount) * 100)
: 0;
},
{
id: "approvalRate",
header: "Approval Rate",
cell: (info) => (
<div className="flex items-center h-[32px]">{info.getValue()}%</div>
),
},
),
columnHelper.accessor("submissionCount", {
header: "Submissions",
cell: (info) => (
<div className="flex items-center text-[#111111] font-medium h-[32px]">
{info.getValue()}
</div>
),
}),
columnHelper.accessor("feedSubmissions", {
header: "Top Feeds",
cell: (info) => {
const feedSubmissions = info.getValue();
const rowIndex = info.row.index;

return (
<div className="flex flex-col min-h-[32px] justify-center">
<div className="flex items-center gap-2">
{feedSubmissions && feedSubmissions.length > 0 && (
<div className="flex items-center justify-between gap-1 border border-neutral-400 px-2 py-1 rounded-md w-[150px]">
<span className="text-sm">#{feedSubmissions[0].feedId}</span>
<span className="text-sm">
{feedSubmissions[0].count}/{feedSubmissions[0].totalInFeed}
</span>
</div>
)}

{feedSubmissions && feedSubmissions.length > 1 && (
<button
onClick={() => toggleRow(rowIndex)}
className="w-8 h-8 flex items-center justify-center border border-neutral-400 rounded-md transition-colors"
>
{expandedRows.includes(rowIndex) ? (
<ChevronUp className="h-4 w-4" />
) : (
<span className="text-xs">
+{feedSubmissions.length - 1}
</span>
)}
</button>
)}
Comment on lines +99 to +112
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add accessibility attributes to the expand button

The expand button should include proper ARIA attributes for better screen reader support.

                 <button
                   onClick={() => toggleRow(rowIndex)}
                   className="w-8 h-8 flex items-center justify-center border border-neutral-400 rounded-md transition-colors"
+                  aria-expanded={expandedRows.includes(rowIndex)}
+                  aria-label={expandedRows.includes(rowIndex) ? "Collapse additional feeds" : `Show ${feedSubmissions.length - 1} more feeds`}
                 >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{feedSubmissions && feedSubmissions.length > 1 && (
<button
onClick={() => toggleRow(rowIndex)}
className="w-8 h-8 flex items-center justify-center border border-neutral-400 rounded-md transition-colors"
>
{expandedRows.includes(rowIndex) ? (
<ChevronUp className="h-4 w-4" />
) : (
<span className="text-xs">
+{feedSubmissions.length - 1}
</span>
)}
</button>
)}
{feedSubmissions && feedSubmissions.length > 1 && (
<button
onClick={() => toggleRow(rowIndex)}
className="w-8 h-8 flex items-center justify-center border border-neutral-400 rounded-md transition-colors"
aria-expanded={expandedRows.includes(rowIndex)}
aria-label={
expandedRows.includes(rowIndex)
? "Collapse additional feeds"
: `Show ${feedSubmissions.length - 1} more feeds`
}
>
{expandedRows.includes(rowIndex) ? (
<ChevronUp className="h-4 w-4" />
) : (
<span className="text-xs">
+{feedSubmissions.length - 1}
</span>
)}
</button>
)}
🤖 Prompt for AI Agents
In apps/app/src/components/leaderboard/LeaderboardColumns.tsx around lines 99 to
112, the expand button lacks ARIA attributes for accessibility. Add appropriate
ARIA attributes such as aria-expanded to indicate the toggle state and
aria-controls to reference the expandable content. This will improve screen
reader support and make the button's function clear to assistive technologies.

</div>

{feedSubmissions && expandedRows.includes(rowIndex) && (
<div className="flex flex-col gap-2 mt-2 pl-0">
{feedSubmissions.slice(1).map((feed, feedIndex) => (
<div key={feedIndex} className="flex items-center">
<div className="flex items-center gap-1 border border-neutral-400 px-2 py-1 rounded-md justify-between w-[150px]">
<span className="text-sm">#{feed.feedId}</span>
<span className="text-sm">
{feed.count}/{feed.totalInFeed}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
},
}),
];
}
144 changes: 144 additions & 0 deletions apps/app/src/components/leaderboard/LeaderboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Link } from "@tanstack/react-router";
import { ChevronDown, Search } from "lucide-react";

interface Feed {
label: string;
value: string;
}

interface TimeOption {
label: string;
value: string;
}

interface LeaderboardFiltersProps {
searchQuery: string | null;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
feeds: Feed[];
timeOptions: TimeOption[];
search: {
feed: string;
timeframe: string;
};
showFeedDropdown: boolean;
showTimeDropdown: boolean;
onFeedDropdownToggle: () => void;
onTimeDropdownToggle: () => void;
onFeedDropdownClose: () => void;
onTimeDropdownClose: () => void;
feedDropdownRef: React.RefObject<HTMLDivElement>;
timeDropdownRef: React.RefObject<HTMLDivElement>;
}

export function LeaderboardFilters({
searchQuery,
onSearchChange,
feeds,
timeOptions,
search,
showFeedDropdown,
showTimeDropdown,
onFeedDropdownToggle,
onTimeDropdownToggle,
onFeedDropdownClose,
onTimeDropdownClose,
feedDropdownRef,
timeDropdownRef,
}: LeaderboardFiltersProps) {
return (
<div className="flex flex-col md:flex-row max-w-[400px] md:max-w-screen-xl md:w-full mx-auto justify-between items-center mb-6 gap-4 px-4 py-8">
<div className="relative w-full md:w-auto">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#a3a3a3] h-4 w-4" />
<input
type="text"
placeholder="Search by curator or feed"
value={searchQuery || ""}
onChange={onSearchChange}
className="pl-10 pr-4 py-2 border border-neutral-300 rounded-md w-full md:w-[300px] focus:outline-none focus:ring-2 focus:ring-[#60a5fa] focus:border-transparent"
/>
</div>
<div className="flex gap-3 w-full md:w-auto">
<div className="relative w-full md:w-auto" ref={feedDropdownRef}>
<button
onClick={onFeedDropdownToggle}
className="flex items-center justify-between gap-2 px-4 py-2 border border-neutral-300 rounded-md bg-white w-full md:w-[180px]"
aria-expanded={showFeedDropdown}
aria-haspopup="listbox"
aria-controls="feed-dropdown"
>
<span className="text-[#111111] text-sm">
{feeds.find((feed) => feed.value === search.feed)?.label}
</span>
<ChevronDown className="h-4 w-4 text-[#64748b]" />
</button>
{showFeedDropdown && (
<div
id="feed-dropdown"
role="listbox"
className="absolute top-full flex flex-col left-0 mt-1 w-full bg-white border border-neutral-200 rounded-md shadow-lg z-20"
>
{feeds.map((feed, index) => (
<Link
key={index}
to="/leaderboard"
search={{ feed: feed.value, timeframe: search.timeframe }}
role="option"
aria-selected={search.feed === feed.value}
onClick={onFeedDropdownClose}
className={`w-full px-4 py-2 text-left hover:bg-neutral-100 text-sm ${
search.feed === feed.value ? "bg-neutral-100" : ""
}`}
>
{feed.label}
</Link>
))}
</div>
)}
</div>
<div className="relative w-full md:w-auto" ref={timeDropdownRef}>
<button
onClick={onTimeDropdownToggle}
className="flex items-center justify-between gap-2 px-4 py-2 border border-neutral-300 rounded-md bg-white w-full md:w-[160px]"
aria-expanded={showTimeDropdown}
aria-haspopup="listbox"
aria-controls="time-dropdown"
>
<span className="text-[#111111] text-sm">
{
timeOptions.find((option) => option.value === search.timeframe)
?.label
}
</span>
<ChevronDown className="h-4 w-4 text-[#64748b]" />
</button>
{showTimeDropdown && (
<div
id="time-dropdown"
role="listbox"
className="absolute top-full flex flex-col left-0 mt-1 w-full bg-white border border-neutral-200 rounded-md shadow-lg z-20"
>
{timeOptions.map((time) => (
<Link
key={time.value}
to="/leaderboard"
search={{
feed: search.feed.toLowerCase(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid unnecessary toLowerCase() on feed value

The search.feed might already be in the correct case. Applying toLowerCase() could cause mismatches if feed IDs are case-sensitive.

-                    feed: search.feed.toLowerCase(),
+                    feed: search.feed,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
feed: search.feed.toLowerCase(),
feed: search.feed,
🤖 Prompt for AI Agents
In apps/app/src/components/leaderboard/LeaderboardFilters.tsx at line 125,
remove the unnecessary toLowerCase() call on search.feed because feed IDs may be
case-sensitive and altering the case could cause mismatches. Use search.feed
directly without converting its case.

timeframe: time.value,
}}
role="option"
aria-selected={search.timeframe === time.label}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix aria-selected comparison logic

The comparison should use time.value instead of time.label to properly indicate the selected state.

-                  aria-selected={search.timeframe === time.label}
+                  aria-selected={search.timeframe === time.value}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
aria-selected={search.timeframe === time.label}
aria-selected={search.timeframe === time.value}
🤖 Prompt for AI Agents
In apps/app/src/components/leaderboard/LeaderboardFilters.tsx at line 129, the
aria-selected attribute is incorrectly comparing search.timeframe with
time.label. Update the comparison to use time.value instead of time.label to
correctly reflect the selected state for accessibility.

onClick={onTimeDropdownClose}
className={`w-full px-4 py-2 text-left hover:bg-neutral-100 text-sm ${
search.timeframe === time.label ? "bg-neutral-100" : ""
}`}
>
{time.label}
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
60 changes: 60 additions & 0 deletions apps/app/src/components/leaderboard/LeaderboardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { TableCell, TableRow } from "../ui/table";

function SkeletonRow() {
return (
<TableRow className="border-b border-[#e5e5e5]">
{/* Rank column */}
<TableCell className="py-2 px-2 align-middle">
<div className="flex items-center w-[35px] h-[32px]">
<div className="w-4 h-4 bg-gray-200 rounded animate-pulse mr-1" />
<div className="w-6 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</TableCell>

{/* Username column */}
<TableCell className="py-2 px-2 align-middle">
<div className="flex items-center gap-2 h-[32px]">
<div className="w-24 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</TableCell>

{/* Approval Rate column */}
<TableCell className="py-2 px-2 align-middle">
<div className="flex items-center h-[32px]">
<div className="w-12 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</TableCell>

{/* Submissions column */}
<TableCell className="py-2 px-2 align-middle">
<div className="flex items-center h-[32px]">
<div className="w-8 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</TableCell>

{/* Top Feeds column */}
<TableCell className="py-2 px-2 align-middle">
<div className="flex flex-col min-h-[40px] justify-center">
<div className="flex items-center gap-2">
<div className="w-[150px] h-8 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</TableCell>
</TableRow>
);
}

interface LeaderboardSkeletonProps {
rows?: number;
}

export function LeaderboardSkeleton({ rows = 8 }: LeaderboardSkeletonProps) {
return (
<>
{Array.from({ length: rows }).map((_, index) => (
<SkeletonRow key={index} />
))}
</>
);
}
Loading