Project_UserExplorer.jsx
React.js/lessons/18_api_integration/projects/Project_UserExplorer.jsx
/**
* Project: GitHub 使用者探索器 — API 串接練習
*
* 學習重點:
* - fetch API 呼叫
* - loading / error / data 三態管理
* - AbortController 取消請求
* - 防抖搜尋
* - Skeleton 載入畫面
*
* 使用方式:將此檔案內容複製到你的 App.jsx 中
* 注意:使用 GitHub 公開 API,無需 API Key
*/
import { useState, useEffect } from 'react';
// 防抖 Hook
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Skeleton 元件
const SkeletonCard = () => (
<div style={{
padding: '16px', borderRadius: '12px', border: '1px solid #f1f5f9', backgroundColor: 'white',
display: 'flex', gap: '16px', alignItems: 'center',
}}>
<div style={{ width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#e2e8f0', animation: 'pulse 1.5s infinite' }} />
<div style={{ flex: 1 }}>
<div style={{ width: '40%', height: '16px', borderRadius: '4px', backgroundColor: '#e2e8f0', marginBottom: '8px', animation: 'pulse 1.5s infinite' }} />
<div style={{ width: '60%', height: '12px', borderRadius: '4px', backgroundColor: '#e2e8f0', animation: 'pulse 1.5s infinite' }} />
</div>
<style>{`@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</div>
);
function UserExplorer() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [userDetail, setUserDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState(null);
const [totalCount, setTotalCount] = useState(0);
const debouncedQuery = useDebounce(query, 400);
// 搜尋使用者
useEffect(() => {
if (!debouncedQuery.trim()) {
setUsers([]);
setTotalCount(0);
return;
}
const controller = new AbortController();
const searchUsers = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`https://api.github.com/search/users?q=${encodeURIComponent(debouncedQuery)}&per_page=12`,
{ signal: controller.signal }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setUsers(data.items || []);
setTotalCount(data.total_count || 0);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
searchUsers();
return () => controller.abort();
}, [debouncedQuery]);
// 取得使用者詳細資料
useEffect(() => {
if (!selectedUser) {
setUserDetail(null);
return;
}
const controller = new AbortController();
const fetchDetail = async () => {
setDetailLoading(true);
try {
const res = await fetch(`https://api.github.com/users/${selectedUser}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setUserDetail(await res.json());
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
}
} finally {
setDetailLoading(false);
}
};
fetchDetail();
return () => controller.abort();
}, [selectedUser]);
return (
<div style={{
maxWidth: '900px', margin: '0 auto', padding: '32px 16px',
fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
}}>
<h1 style={{ color: '#0f172a', marginBottom: '4px' }}>🐙 GitHub User Explorer</h1>
<p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '24px' }}>搜尋 GitHub 使用者</p>
{/* 搜尋框 */}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜尋 GitHub 使用者名稱..."
style={{
width: '100%', padding: '14px 20px', borderRadius: '12px',
border: '2px solid #e2e8f0', fontSize: '1rem', outline: 'none',
boxSizing: 'border-box', marginBottom: '8px',
}}
/>
{debouncedQuery && !loading && (
<p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: '0 0 16px' }}>
找到 {totalCount.toLocaleString()} 位使用者
</p>
)}
{error && (
<div style={{ padding: '16px', borderRadius: '10px', backgroundColor: '#fef2f2', color: '#991b1b', marginBottom: '16px' }}>
錯誤:{error}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: userDetail ? '1fr 300px' : '1fr', gap: '24px', alignItems: 'start' }}>
{/* 搜尋結果 */}
<div>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{[1, 2, 3, 4, 5, 6].map((i) => <SkeletonCard key={i} />)}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '12px' }}>
{users.map((user) => (
<div
key={user.id}
onClick={() => setSelectedUser(user.login)}
style={{
padding: '16px', borderRadius: '12px',
border: `2px solid ${selectedUser === user.login ? '#818cf8' : '#f1f5f9'}`,
backgroundColor: selectedUser === user.login ? '#eef2ff' : 'white',
cursor: 'pointer', display: 'flex', gap: '12px', alignItems: 'center',
transition: 'all 0.2s',
}}
>
<img
src={user.avatar_url}
alt={user.login}
style={{ width: '48px', height: '48px', borderRadius: '50%' }}
/>
<div>
<p style={{ margin: '0 0 2px', fontWeight: '600', color: '#0f172a', fontSize: '0.95rem' }}>
{user.login}
</p>
<a
href={user.html_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{ color: '#4f46e5', fontSize: '0.75rem', textDecoration: 'none' }}
>
查看 GitHub →
</a>
</div>
</div>
))}
</div>
)}
{!loading && !error && debouncedQuery && users.length === 0 && (
<div style={{ textAlign: 'center', padding: '48px 0', color: '#cbd5e1' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>🔍</p>
<p>找不到符合的使用者</p>
</div>
)}
</div>
{/* 使用者詳情面板 */}
{selectedUser && (
<div style={{
padding: '24px', borderRadius: '16px', backgroundColor: 'white',
border: '1px solid #e2e8f0', position: 'sticky', top: '20px',
}}>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#94a3b8' }}>載入中...</div>
) : userDetail ? (
<>
<button onClick={() => setSelectedUser(null)} style={{
background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', float: 'right',
}}>✕</button>
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
<img src={userDetail.avatar_url} alt="" style={{ width: '80px', height: '80px', borderRadius: '50%', marginBottom: '8px' }} />
<h3 style={{ margin: '0 0 2px', color: '#0f172a' }}>{userDetail.name || userDetail.login}</h3>
<p style={{ margin: 0, color: '#64748b', fontSize: '0.85rem' }}>@{userDetail.login}</p>
</div>
{userDetail.bio && (
<p style={{ color: '#475569', fontSize: '0.85rem', lineHeight: 1.5, borderLeft: '3px solid #818cf8', paddingLeft: '12px', margin: '0 0 16px' }}>
{userDetail.bio}
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', marginBottom: '16px' }}>
{[
{ label: 'Repos', value: userDetail.public_repos },
{ label: 'Followers', value: userDetail.followers },
{ label: 'Following', value: userDetail.following },
].map((s) => (
<div key={s.label} style={{ textAlign: 'center', padding: '8px', borderRadius: '8px', backgroundColor: '#f8fafc' }}>
<p style={{ margin: 0, fontWeight: 'bold', color: '#4f46e5' }}>{s.value}</p>
<p style={{ margin: 0, fontSize: '0.7rem', color: '#94a3b8' }}>{s.label}</p>
</div>
))}
</div>
{[
userDetail.company && { icon: '🏢', value: userDetail.company },
userDetail.location && { icon: '📍', value: userDetail.location },
userDetail.blog && { icon: '🔗', value: userDetail.blog },
].filter(Boolean).map((info, i) => (
<div key={i} style={{ display: 'flex', gap: '8px', padding: '4px 0', fontSize: '0.85rem', color: '#475569' }}>
<span>{info.icon}</span>
<span>{info.value}</span>
</div>
))}
</>
) : null}
</div>
)}
</div>
</div>
);
}
export default UserExplorer;
Related articles
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Read article →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Read article →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Read article →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Read article →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Read article →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Read article →