S SmartDocs
Serie: React.js javascript 255 líneas · Actualizado 2026-04-02

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;

Artículos relacionados