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..219d1e6d --- /dev/null +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Navbar from '../components/Navbar'; +import './LeaderboardPage.css'; + +const REPO = 'Dev-Card/DevCard'; +const GITHUB_API = 'https://api.github.com'; + +// Maintainers / accounts excluded from the contributor leaderboard. +const EXCLUDED = new Set( + ['ShantKhatri', 'Harxhit', 'blankirigaya'].map((u) => u.toLowerCase()) +); + +type Contributor = { + login: string; + avatarUrl: string; + profileUrl: string; + issues: number; + mergedPrs: number; + openPrs: number; +}; + +type GithubContributor = { + login: string; + avatar_url: string; + html_url: string; + type: string; +}; + +type SearchResult = { total_count: number }; + +async function ghJson(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) { + throw new Error(`GitHub request failed: ${response.status}`); + } + return response.json() as Promise; +} + +async function countSearch(query: string): Promise { + const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(query)}&per_page=1`; + const result = await ghJson(url); + return result.total_count; +} + +async function loadContributorStats(login: string): Promise> { + const base = `repo:${REPO} author:${login}`; + const [issues, mergedPrs, openPrs] = await Promise.all([ + countSearch(`${base} type:issue`), + countSearch(`${base} type:pr is:merged`), + countSearch(`${base} type:pr is:open`), + ]); + return { issues, mergedPrs, openPrs }; +} + +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 list = await ghJson( + `${GITHUB_API}/repos/${REPO}/contributors?per_page=100` + ); + + const eligible = list.filter( + (c) => c.type === 'User' && !EXCLUDED.has(c.login.toLowerCase()) + ); + + const enriched = await Promise.all( + eligible.map(async (c) => { + const stats = await loadContributorStats(c.login); + return { + login: c.login, + avatarUrl: c.avatar_url, + profileUrl: c.html_url, + ...stats, + } satisfies Contributor; + }) + ); + + enriched.sort( + (a, b) => + b.mergedPrs - a.mergedPrs || + b.issues - a.issues || + b.openPrs - a.openPrs || + a.login.localeCompare(b.login) + ); + + if (!cancelled) setContributors(enriched); + } catch { + if (!cancelled) setError('Could not load contributors. GitHub may be rate-limiting — try again shortly.'); + } 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 + +
+
+ + ); +}