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
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>
)}
</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(),
timeframe: time.value,
}}
role="option"
aria-selected={search.timeframe === time.label}
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