diff --git a/.github/scripts/generateLeaderboard.js b/.github/scripts/generateLeaderboard.js new file mode 100644 index 00000000..47e4225f --- /dev/null +++ b/.github/scripts/generateLeaderboard.js @@ -0,0 +1,130 @@ +module.exports = async ({ github, context }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + + const EXCLUDED = new Set([ + 'shantkhatri', + 'harxhit', + 'blankirigaya' + ]); + + const contributors = new Map(); + + const ensure = (login, avatarUrl, profileUrl) => { + if (!contributors.has(login)) { + contributors.set(login, { + login, + avatarUrl, + profileUrl, + mergedPrs: 0, + openPrs: 0, + issues: 0 + }); + } + + return contributors.get(login); + }; + + const mergedPrs = await github.paginate( + github.rest.pulls.list, + { + owner, + repo, + state: 'closed', + per_page: 100 + } + ); + + for (const pr of mergedPrs) { + if (!pr.merged_at || !pr.user) continue; + + const login = pr.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + pr.user.avatar_url, + pr.user.html_url + ); + + user.mergedPrs++; + } + + const openPrs = await github.paginate( + github.rest.pulls.list, + { + owner, + repo, + state: 'open', + per_page: 100 + } + ); + + for (const pr of openPrs) { + if (!pr.user) continue; + + const login = pr.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + pr.user.avatar_url, + pr.user.html_url + ); + + user.openPrs++; + } + + const issues = await github.paginate( + github.rest.issues.listForRepo, + { + owner, + repo, + state: 'all', + per_page: 100 + } + ); + + for (const issue of issues) { + if (issue.pull_request || !issue.user) continue; + + const login = issue.user.login; + + if (EXCLUDED.has(login.toLowerCase())) continue; + + const user = ensure( + login, + issue.user.avatar_url, + issue.user.html_url + ); + + user.issues++; + } + + const leaderboard = [...contributors.values()].sort( + (a, b) => + b.mergedPrs - a.mergedPrs || + b.issues - a.issues || + b.openPrs - a.openPrs || + a.login.localeCompare(b.login) + ); + + const fs = require('fs'); + const path = require('path'); + + const outputDir = path.join('apps', 'web', 'public'); + const outputFile = path.join(outputDir, 'leaderboard.json'); + + fs.mkdirSync(outputDir, { recursive: true }); + + fs.writeFileSync( + outputFile, + JSON.stringify(leaderboard, null, 2), + 'utf8' + ); + + console.log(`Generated ${leaderboard.length} contributors`); + console.log(`Leaderboard written to ${outputFile}`); +}; \ No newline at end of file diff --git a/.github/workflows/leaderboard.yml b/.github/workflows/leaderboard.yml new file mode 100644 index 00000000..f3356f83 --- /dev/null +++ b/.github/workflows/leaderboard.yml @@ -0,0 +1,48 @@ +name: Update Leaderboard + +on: +workflow_dispatch: +schedule: +- cron: '0 * * * *' + +permissions: +contents: write +pull-requests: read +issues: read + +jobs: +leaderboard: +runs-on: ubuntu-latest + +steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Generate leaderboard + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/scripts/generateLeaderboard.js'); + await script({ github, context }); + + - name: Commit leaderboard + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add public/leaderboard.json + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "chore: update leaderboard" + git push + diff --git a/README.md b/README.md index 2690181e..53b82a00 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,8 @@ Thanks to all the amazing people who contribute to **DevCard** 🚀

+> 🏆 **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`). +
## Project Support diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 49c29037..8dd9dc07 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'; import LandingPage from './pages/LandingPage'; import ProfilePage from './pages/ProfilePage'; import CardPage from './pages/CardPage'; +import LeaderboardPage from './pages/LeaderboardPage'; import NotFound from './pages/NotFound'; export default function App() { @@ -10,6 +11,7 @@ export default function App() { } /> } /> } /> + } /> } /> ); diff --git a/apps/web/src/pages/LeaderboardPage.css b/apps/web/src/pages/LeaderboardPage.css new file mode 100644 index 00000000..d4daa973 --- /dev/null +++ b/apps/web/src/pages/LeaderboardPage.css @@ -0,0 +1,145 @@ +.leaderboard { + max-width: 860px; + margin: 0 auto; + padding: 7rem 1.5rem 4rem; +} + +.leaderboard-header { + text-align: center; + margin-bottom: 3rem; +} + +.leaderboard-header h1 { + font-size: clamp(2.2rem, 5vw, 3.2rem); + font-weight: 900; + font-family: 'Outfit', sans-serif; + margin: 1rem 0 0.75rem; +} + +.leaderboard-subtitle { + color: var(--text-secondary); + line-height: 1.7; + max-width: 520px; + margin: 0 auto; +} + +.leaderboard-state { + text-align: center; + padding: 2.5rem 1.5rem; + border-radius: var(--radius-lg); + color: var(--text-secondary); +} + +.leaderboard-state.error { + color: #ef4444; +} + +.leaderboard-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + margin: 0; +} + +.leaderboard-row { + display: grid; + grid-template-columns: 2.5rem 1fr auto; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: var(--radius-lg); +} + +.rank { + font-weight: 800; + font-size: 1.1rem; + text-align: center; + color: var(--text-muted); +} + +.rank-1 { color: #f5c518; } +.rank-2 { color: #c0c5ce; } +.rank-3 { color: #cd7f32; } + +.contributor { + display: flex; + align-items: center; + gap: 0.85rem; + text-decoration: none; + color: var(--text-primary); + min-width: 0; +} + +.contributor-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + border: 2px solid var(--border-glass); + flex-shrink: 0; +} + +.contributor-name { + font-weight: 600; + font-family: 'Outfit', sans-serif; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contributor:hover .contributor-name { + color: var(--primary); +} + +.stats { + display: flex; + gap: 1.25rem; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 3.5rem; +} + +.stat-value { + font-weight: 800; + font-size: 1.15rem; + color: var(--text-primary); +} + +.stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.leaderboard-footer { + text-align: center; + margin-top: 2.5rem; +} + +.leaderboard-footer a { + text-decoration: none; + font-weight: 600; +} + +@media (max-width: 600px) { + .leaderboard-row { + grid-template-columns: 2rem 1fr; + grid-template-areas: + 'rank contributor' + 'stats stats'; + row-gap: 0.85rem; + } + .rank { grid-area: rank; } + .contributor { grid-area: contributor; } + .stats { + grid-area: stats; + justify-content: space-around; + width: 100%; + } +} diff --git a/apps/web/src/pages/LeaderboardPage.tsx b/apps/web/src/pages/LeaderboardPage.tsx new file mode 100644 index 00000000..e90f1ef2 --- /dev/null +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import './LeaderboardPage.css'; + +type Contributor = { +login: string; +avatarUrl: string; +profileUrl: string; +issues: number; +mergedPrs: number; +openPrs: number; +}; + +export default function LeaderboardPage() { +const [contributors, setContributors] = useState([]); +const [loading, setLoading] = useState(true); +const [error, setError] = useState(null); + +useEffect(() => { +document.title = 'Contributor Leaderboard | DevCard'; +}, []); + +useEffect(() => { +let cancelled = false; + + +async function load() { + setLoading(true); + setError(null); + + try { + const response = await fetch('/leaderboard.json', { + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error('Failed to load leaderboard'); + } + + const data: Contributor[] = await response.json(); + + if (!cancelled) { + setContributors(data); + } + } catch { + if (!cancelled) { + console.error(error); + setError('Could not load leaderboard.'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } +} + +load(); + +return () => { + cancelled = true; +}; + + +}, []); + +return ( +<>
+ +
+
+
🏆 Community
+ +

+ Contributor{' '} + Leaderboard +

+ +

+ The developers building DevCard, ranked by merged + pull requests, issues, and open work. +

+
+ + {loading && ( +
+ Loading contributors… +
+ )} + + {error && !loading && ( +
+ {error} +
+ )} + + {!loading && !error && ( +
    + {contributors.map((c, i) => ( +
  1. + + {i + 1} + + + + {c.login} + + + @{c.login} + + + +
    + + + {c.mergedPrs} + + + + Merged PRs + + + + + + {c.openPrs} + + + + Open PRs + + + + + + {c.issues} + + + + Issues + + +
    +
  2. + ))} +
+ )} + +
+ + ← Back to Home + +
+
+); +}