Skip to content

Commit 664bce5

Browse files
beihailiclaude
andcommitted
feat: add contributors page with GitHub API
- Add ContributorsPage fetching GitHub contributors API with sessionStorage cache - Display responsive 2-4 column grid with avatar, username, and contribution count - Add i18n keys (contributors.*) to both en.json and zh.json - Register /:lang/contributors route as lazy-loaded SuspenseWrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 64ece0b commit 664bce5

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/i18n/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@
148148
"noSponsors": "No sponsors yet. Be the first!",
149149
"backHome": "Back to Home"
150150
},
151+
"contributors": {
152+
"pageTitle": "Contributors",
153+
"pageDesc": "Meet the people who built this platform",
154+
"heading": "Our Contributors",
155+
"contributions": "contributions",
156+
"loading": "Loading contributors...",
157+
"error": "Failed to load contributors"
158+
},
151159
"donation": {
152160
"title": "Support This Project",
153161
"subtitle": "This tutorial is completely free and open source. If it has been helpful to you, please consider supporting the author to keep it updated ✨",

src/i18n/locales/zh.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@
148148
"noSponsors": "暂无赞助商,欢迎成为第一位!",
149149
"backHome": "返回首页"
150150
},
151+
"contributors": {
152+
"pageTitle": "Contributors",
153+
"pageDesc": "认识共同建设这个平台的人们",
154+
"heading": "项目贡献者",
155+
"contributions": "次贡献",
156+
"loading": "加载贡献者中...",
157+
"error": "贡献者列表加载失败"
158+
},
151159
"donation": {
152160
"title": "支持本项目",
153161
"subtitle": "本教程完全免费开源,如果对你有帮助,欢迎支持作者持续更新 ✨",

src/pages/ContributorsPage.jsx

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { useState, useEffect } from 'react';
2+
import { Link, useParams } from 'react-router-dom';
3+
import { useTranslation } from 'react-i18next';
4+
import { ArrowLeft, Users } from 'lucide-react';
5+
import SeoHead from '../components/SeoHead';
6+
import LanguageSwitcher from '../components/LanguageSwitcher';
7+
8+
const CACHE_KEY = 'gh_contributors_cache';
9+
const API_URL = 'https://api.github.com/repos/beihaili/Get-Started-with-Web3/contributors';
10+
11+
/**
12+
* Fetches contributors from GitHub API with sessionStorage caching
13+
* to avoid hitting rate limits on page re-visits.
14+
*/
15+
async function fetchContributors() {
16+
const cached = sessionStorage.getItem(CACHE_KEY);
17+
if (cached) {
18+
return JSON.parse(cached);
19+
}
20+
21+
const res = await fetch(`${API_URL}?per_page=100`);
22+
if (!res.ok) {
23+
throw new Error(`GitHub API error: ${res.status}`);
24+
}
25+
const data = await res.json();
26+
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
27+
return data;
28+
}
29+
30+
/**
31+
* ContributorsPage — displays all GitHub contributors with avatar,
32+
* username, and contribution count in a responsive grid.
33+
*/
34+
const ContributorsPage = () => {
35+
const { lang } = useParams();
36+
const { t } = useTranslation();
37+
38+
const [contributors, setContributors] = useState([]);
39+
const [loading, setLoading] = useState(true);
40+
const [error, setError] = useState(null);
41+
42+
useEffect(() => {
43+
let cancelled = false;
44+
setLoading(true);
45+
setError(null);
46+
47+
fetchContributors()
48+
.then((data) => {
49+
if (!cancelled) {
50+
setContributors(data);
51+
setLoading(false);
52+
}
53+
})
54+
.catch((err) => {
55+
if (!cancelled) {
56+
setError(err.message);
57+
setLoading(false);
58+
}
59+
});
60+
61+
return () => {
62+
cancelled = true;
63+
};
64+
}, []);
65+
66+
const siteUrl = 'https://beihaili.github.io/Get-Started-with-Web3/';
67+
const canonicalUrl = `${siteUrl}${lang}/contributors`;
68+
const altLang = lang === 'en' ? 'zh' : 'en';
69+
const alternateUrl = `${siteUrl}${altLang}/contributors`;
70+
71+
return (
72+
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
73+
<SeoHead
74+
title={t('contributors.pageTitle')}
75+
description={t('contributors.pageDesc')}
76+
url={canonicalUrl}
77+
type="webpage"
78+
lang={lang}
79+
alternateUrl={alternateUrl}
80+
/>
81+
82+
{/* Top nav bar */}
83+
<div className="sticky top-0 z-20 bg-slate-950/80 backdrop-blur-md border-b border-slate-800">
84+
<div className="container mx-auto px-4 h-14 flex items-center justify-between">
85+
<Link
86+
to={`/${lang}`}
87+
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors text-sm"
88+
>
89+
<ArrowLeft className="w-4 h-4" />
90+
{t('support.backHome')}
91+
</Link>
92+
<LanguageSwitcher />
93+
</div>
94+
</div>
95+
96+
<main className="container mx-auto px-4 py-12 max-w-4xl">
97+
{/* Page heading */}
98+
<div className="text-center mb-12">
99+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-cyan-500 to-purple-500 mb-6 shadow-lg shadow-cyan-500/20">
100+
<Users className="w-8 h-8 text-white" />
101+
</div>
102+
<h1 className="text-3xl sm:text-4xl font-black text-white mb-3">
103+
{t('contributors.heading')}
104+
</h1>
105+
<p className="text-slate-400 text-lg max-w-xl mx-auto">{t('contributors.pageDesc')}</p>
106+
</div>
107+
108+
{/* Loading state */}
109+
{loading && (
110+
<div className="flex flex-col items-center justify-center py-20 gap-4">
111+
<div className="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-500" />
112+
<p className="text-slate-400">{t('contributors.loading')}</p>
113+
</div>
114+
)}
115+
116+
{/* Error state */}
117+
{error && !loading && (
118+
<div className="text-center py-16 text-red-400 bg-slate-900/40 border border-dashed border-red-700/50 rounded-xl">
119+
{t('contributors.error')}
120+
</div>
121+
)}
122+
123+
{/* Contributors grid */}
124+
{!loading && !error && (
125+
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
126+
{contributors.map((contributor) => (
127+
<a
128+
key={contributor.id}
129+
href={contributor.html_url}
130+
target="_blank"
131+
rel="noopener noreferrer"
132+
className="flex flex-col items-center gap-3 p-5 bg-slate-900/60 border border-slate-700/50
133+
hover:border-cyan-500/40 rounded-xl transition-all hover:scale-[1.03] group"
134+
>
135+
{/* Avatar */}
136+
<img
137+
src={contributor.avatar_url}
138+
alt={contributor.login}
139+
className="w-16 h-16 rounded-full border-2 border-slate-700 group-hover:border-cyan-500/60 transition-colors"
140+
loading="lazy"
141+
/>
142+
{/* Username */}
143+
<span className="text-white font-semibold text-sm text-center truncate w-full group-hover:text-cyan-400 transition-colors">
144+
{contributor.login}
145+
</span>
146+
{/* Contribution count */}
147+
<span className="text-xs text-slate-400">
148+
{contributor.contributions} {t('contributors.contributions')}
149+
</span>
150+
</a>
151+
))}
152+
</div>
153+
)}
154+
</main>
155+
</div>
156+
);
157+
};
158+
159+
export default ContributorsPage;

src/routes/index.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const ReaderPage = lazy(() => import('../pages/ReaderPage'));
1313
const BadgeCollectionPage = lazy(() => import('../pages/BadgeCollectionPage'));
1414
const ArticlesPage = lazy(() => import('../pages/ArticlesPage'));
1515
const SupportPage = lazy(() => import('../pages/SupportPage'));
16+
const ContributorsPage = lazy(() => import('../pages/ContributorsPage'));
1617

1718
// Loading component
1819
const PageLoader = () => {
@@ -163,6 +164,14 @@ export const router = createBrowserRouter(
163164
</SuspenseWrapper>
164165
),
165166
},
167+
{
168+
path: 'contributors',
169+
element: (
170+
<SuspenseWrapper>
171+
<ContributorsPage />
172+
</SuspenseWrapper>
173+
),
174+
},
166175
{
167176
path: '*',
168177
element: <Navigate to="/" replace />,

0 commit comments

Comments
 (0)