Skip to content

Commit b326f06

Browse files
Harxhitclaude
andauthored
feat(web): add contributor leaderboard page (#592)
* feat(web): add contributor leaderboard page Add a /leaderboard route to apps/web that ranks repo contributors by merged PRs, issues created, and open PRs (data from the GitHub API), showing each contributor's avatar and GitHub username. Mention the route in the README Contributors section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: generate leaderboard data via GitHub Actions * feat: Leaderboard yaml * fix: Updated leaderboard script --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fdf594f commit b326f06

6 files changed

Lines changed: 512 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
module.exports = async ({ github, context }) => {
2+
const owner = context.repo.owner;
3+
const repo = context.repo.repo;
4+
5+
const EXCLUDED = new Set([
6+
'shantkhatri',
7+
'harxhit',
8+
'blankirigaya'
9+
]);
10+
11+
const contributors = new Map();
12+
13+
const ensure = (login, avatarUrl, profileUrl) => {
14+
if (!contributors.has(login)) {
15+
contributors.set(login, {
16+
login,
17+
avatarUrl,
18+
profileUrl,
19+
mergedPrs: 0,
20+
openPrs: 0,
21+
issues: 0
22+
});
23+
}
24+
25+
return contributors.get(login);
26+
};
27+
28+
const mergedPrs = await github.paginate(
29+
github.rest.pulls.list,
30+
{
31+
owner,
32+
repo,
33+
state: 'closed',
34+
per_page: 100
35+
}
36+
);
37+
38+
for (const pr of mergedPrs) {
39+
if (!pr.merged_at || !pr.user) continue;
40+
41+
const login = pr.user.login;
42+
43+
if (EXCLUDED.has(login.toLowerCase())) continue;
44+
45+
const user = ensure(
46+
login,
47+
pr.user.avatar_url,
48+
pr.user.html_url
49+
);
50+
51+
user.mergedPrs++;
52+
}
53+
54+
const openPrs = await github.paginate(
55+
github.rest.pulls.list,
56+
{
57+
owner,
58+
repo,
59+
state: 'open',
60+
per_page: 100
61+
}
62+
);
63+
64+
for (const pr of openPrs) {
65+
if (!pr.user) continue;
66+
67+
const login = pr.user.login;
68+
69+
if (EXCLUDED.has(login.toLowerCase())) continue;
70+
71+
const user = ensure(
72+
login,
73+
pr.user.avatar_url,
74+
pr.user.html_url
75+
);
76+
77+
user.openPrs++;
78+
}
79+
80+
const issues = await github.paginate(
81+
github.rest.issues.listForRepo,
82+
{
83+
owner,
84+
repo,
85+
state: 'all',
86+
per_page: 100
87+
}
88+
);
89+
90+
for (const issue of issues) {
91+
if (issue.pull_request || !issue.user) continue;
92+
93+
const login = issue.user.login;
94+
95+
if (EXCLUDED.has(login.toLowerCase())) continue;
96+
97+
const user = ensure(
98+
login,
99+
issue.user.avatar_url,
100+
issue.user.html_url
101+
);
102+
103+
user.issues++;
104+
}
105+
106+
const leaderboard = [...contributors.values()].sort(
107+
(a, b) =>
108+
b.mergedPrs - a.mergedPrs ||
109+
b.issues - a.issues ||
110+
b.openPrs - a.openPrs ||
111+
a.login.localeCompare(b.login)
112+
);
113+
114+
const fs = require('fs');
115+
const path = require('path');
116+
117+
const outputDir = path.join('apps', 'web', 'public');
118+
const outputFile = path.join(outputDir, 'leaderboard.json');
119+
120+
fs.mkdirSync(outputDir, { recursive: true });
121+
122+
fs.writeFileSync(
123+
outputFile,
124+
JSON.stringify(leaderboard, null, 2),
125+
'utf8'
126+
);
127+
128+
console.log(`Generated ${leaderboard.length} contributors`);
129+
console.log(`Leaderboard written to ${outputFile}`);
130+
};

.github/workflows/leaderboard.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Update Leaderboard
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: '0 * * * *'
7+
8+
permissions:
9+
contents: write
10+
pull-requests: read
11+
issues: read
12+
13+
jobs:
14+
leaderboard:
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup Node
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: 22
25+
26+
- name: Generate leaderboard
27+
uses: actions/github-script@v7
28+
with:
29+
github-token: ${{ secrets.GITHUB_TOKEN }}
30+
script: |
31+
const script = require('./.github/scripts/generateLeaderboard.js');
32+
await script({ github, context });
33+
34+
- name: Commit leaderboard
35+
run: |
36+
git config user.name "github-actions[bot]"
37+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
38+
39+
git add public/leaderboard.json
40+
41+
if git diff --staged --quiet; then
42+
echo "No changes to commit"
43+
exit 0
44+
fi
45+
46+
git commit -m "chore: update leaderboard"
47+
git push
48+

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ Thanks to all the amazing people who contribute to **DevCard** 🚀
307307
</a>
308308
</p>
309309

310+
> 🏆 **Contributor Leaderboard** — contributors are also ranked by merged PRs, issues, and open PRs in the web app at the [`/leaderboard`](https://devcard.app/leaderboard) route (`apps/web`).
311+
310312
<br>
311313

312314
## Project Support

apps/web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom';
22
import LandingPage from './pages/LandingPage';
33
import ProfilePage from './pages/ProfilePage';
44
import CardPage from './pages/CardPage';
5+
import LeaderboardPage from './pages/LeaderboardPage';
56
import NotFound from './pages/NotFound';
67

78
export default function App() {
@@ -10,6 +11,7 @@ export default function App() {
1011
<Route path="/" element={<LandingPage />} />
1112
<Route path="/u/:username" element={<ProfilePage />} />
1213
<Route path="/devcard/:id" element={<CardPage />} />
14+
<Route path="/leaderboard" element={<LeaderboardPage />} />
1315
<Route path="*" element={<NotFound />} />
1416
</Routes>
1517
);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
.leaderboard {
2+
max-width: 860px;
3+
margin: 0 auto;
4+
padding: 7rem 1.5rem 4rem;
5+
}
6+
7+
.leaderboard-header {
8+
text-align: center;
9+
margin-bottom: 3rem;
10+
}
11+
12+
.leaderboard-header h1 {
13+
font-size: clamp(2.2rem, 5vw, 3.2rem);
14+
font-weight: 900;
15+
font-family: 'Outfit', sans-serif;
16+
margin: 1rem 0 0.75rem;
17+
}
18+
19+
.leaderboard-subtitle {
20+
color: var(--text-secondary);
21+
line-height: 1.7;
22+
max-width: 520px;
23+
margin: 0 auto;
24+
}
25+
26+
.leaderboard-state {
27+
text-align: center;
28+
padding: 2.5rem 1.5rem;
29+
border-radius: var(--radius-lg);
30+
color: var(--text-secondary);
31+
}
32+
33+
.leaderboard-state.error {
34+
color: #ef4444;
35+
}
36+
37+
.leaderboard-list {
38+
list-style: none;
39+
display: flex;
40+
flex-direction: column;
41+
gap: 0.75rem;
42+
padding: 0;
43+
margin: 0;
44+
}
45+
46+
.leaderboard-row {
47+
display: grid;
48+
grid-template-columns: 2.5rem 1fr auto;
49+
align-items: center;
50+
gap: 1rem;
51+
padding: 1rem 1.25rem;
52+
border-radius: var(--radius-lg);
53+
}
54+
55+
.rank {
56+
font-weight: 800;
57+
font-size: 1.1rem;
58+
text-align: center;
59+
color: var(--text-muted);
60+
}
61+
62+
.rank-1 { color: #f5c518; }
63+
.rank-2 { color: #c0c5ce; }
64+
.rank-3 { color: #cd7f32; }
65+
66+
.contributor {
67+
display: flex;
68+
align-items: center;
69+
gap: 0.85rem;
70+
text-decoration: none;
71+
color: var(--text-primary);
72+
min-width: 0;
73+
}
74+
75+
.contributor-avatar {
76+
width: 44px;
77+
height: 44px;
78+
border-radius: 50%;
79+
border: 2px solid var(--border-glass);
80+
flex-shrink: 0;
81+
}
82+
83+
.contributor-name {
84+
font-weight: 600;
85+
font-family: 'Outfit', sans-serif;
86+
overflow: hidden;
87+
text-overflow: ellipsis;
88+
white-space: nowrap;
89+
}
90+
91+
.contributor:hover .contributor-name {
92+
color: var(--primary);
93+
}
94+
95+
.stats {
96+
display: flex;
97+
gap: 1.25rem;
98+
}
99+
100+
.stat {
101+
display: flex;
102+
flex-direction: column;
103+
align-items: center;
104+
min-width: 3.5rem;
105+
}
106+
107+
.stat-value {
108+
font-weight: 800;
109+
font-size: 1.15rem;
110+
color: var(--text-primary);
111+
}
112+
113+
.stat-label {
114+
font-size: 0.7rem;
115+
text-transform: uppercase;
116+
letter-spacing: 0.04em;
117+
color: var(--text-muted);
118+
}
119+
120+
.leaderboard-footer {
121+
text-align: center;
122+
margin-top: 2.5rem;
123+
}
124+
125+
.leaderboard-footer a {
126+
text-decoration: none;
127+
font-weight: 600;
128+
}
129+
130+
@media (max-width: 600px) {
131+
.leaderboard-row {
132+
grid-template-columns: 2rem 1fr;
133+
grid-template-areas:
134+
'rank contributor'
135+
'stats stats';
136+
row-gap: 0.85rem;
137+
}
138+
.rank { grid-area: rank; }
139+
.contributor { grid-area: contributor; }
140+
.stats {
141+
grid-area: stats;
142+
justify-content: space-around;
143+
width: 100%;
144+
}
145+
}

0 commit comments

Comments
 (0)