Skip to content

Component Reference

Makoto Horikawa edited this page Jul 9, 2025 · 1 revision

⚛️ コンポーネントリファレンス

GitHub Power ScouterのReactコンポーネントの詳細リファレンス。各コンポーネントの使用方法、Props、内部実装について詳しく解説します。

🏗️ コンポーネント構造

graph TD
    A[App] --> B[UserInput]
    A --> C[ScouterDisplay]
    A --> D[ResumeModal]
    
    B --> E[TokenManager]
    B --> F[ScanButton]
    
    C --> G[PowerLevelDisplay]
    C --> H[StatsDisplay]
    C --> I[LanguageStats]
    C --> J[AnimationEngine]
    
    D --> K[LanguageSection]
    D --> L[TechStackSection]
    D --> M[AchievementSection]
    
    style A fill:#e1f5fe
    style B fill:#f3e5f5
    style C fill:#e8f5e8
    style D fill:#fff3e0
Loading

📱 App コンポーネント

概要

アプリケーションのルートコンポーネント。全体の状態管理とコンポーネント間の連携を担当します。

Props

// App は Props を受け取らない
interface AppProps {}

状態管理

interface AppState {
  isScanning: boolean;
  scanData: PowerLevelResult | null;
  detailedTechData: DetailedTechData | null;
  isResumeOpen: boolean;
  currentUsername: string;
  error: string | null;
}

実装例

const App: React.FC = () => {
  const [isScanning, setIsScanning] = useState(false);
  const [scanData, setScanData] = useState<PowerLevelResult | null>(null);
  const [detailedTechData, setDetailedTechData] = useState<DetailedTechData | null>(null);
  const [isResumeOpen, setIsResumeOpen] = useState(false);
  const [currentUsername, setCurrentUsername] = useState('');
  const [error, setError] = useState<string | null>(null);

  const handleScan = useCallback((username: string) => {
    setCurrentUsername(username);
    setIsScanning(true);
    setScanData(null);
    setDetailedTechData(null);
    setError(null);
  }, []);

  const handleScanComplete = useCallback((
    data: PowerLevelResult, 
    techData: DetailedTechData
  ) => {
    setScanData(data);
    setDetailedTechData(techData);
    setIsScanning(false);
  }, []);

  const handleScanError = useCallback((errorMessage: string) => {
    setError(errorMessage);
    setIsScanning(false);
    setScanData(null);
    setDetailedTechData(null);
  }, []);

  return (
    <div className="app">
      <UserInput 
        onScan={handleScan}
        isScanning={isScanning}
        onShowResume={() => setIsResumeOpen(true)}
        hasDetailedData={!!detailedTechData}
        error={error}
      />
      
      <ScouterDisplay 
        isScanning={isScanning}
        scanData={scanData}
        username={currentUsername}
        onScanComplete={handleScanComplete}
        onScanError={handleScanError}
      />
      
      <ResumeModal 
        isOpen={isResumeOpen}
        onClose={() => setIsResumeOpen(false)}
        techData={detailedTechData}
      />
    </div>
  );
};

🎛️ UserInput コンポーネント

概要

ユーザー入力フォームとGitHubトークン管理を担当するコンポーネント。

Props

interface UserInputProps {
  onScan: (username: string) => void;
  isScanning: boolean;
  onShowResume: () => void;
  hasDetailedData: boolean;
  error?: string | null;
}

主要機能

  • ユーザー名入力
  • GitHubトークン管理
  • スキャン実行
  • レート制限表示
  • エラー表示

実装例

const UserInput: React.FC<UserInputProps> = ({
  onScan,
  isScanning,
  onShowResume,
  hasDetailedData,
  error
}) => {
  const [username, setUsername] = useState('torvalds');
  const [token, setToken] = useState('');
  const [tokenStatus, setTokenStatus] = useState('');
  const [rateLimit, setRateLimit] = useState<GitHubRateLimit | null>(null);

  // トークン保存処理
  const handleSaveToken = useCallback(() => {
    const tokenValue = token.trim();
    if (!tokenValue) {
      setTokenStatus('トークンを入力してください');
      return;
    }
    
    if (!tokenValue.startsWith('ghp_') && !tokenValue.startsWith('github_pat_')) {
      setTokenStatus('無効なトークン形式です');
      return;
    }
    
    TokenManager.saveToken(tokenValue);
    setTokenStatus('トークンを保存しました');
    setToken(''); // セキュリティのためクリア
    checkRateLimit(tokenValue);
  }, [token]);

  // スキャン実行処理
  const handleScan = useCallback(() => {
    if (username.trim()) {
      onScan(username.trim());
    }
  }, [username, onScan]);

  // エンターキーでスキャン実行
  const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !isScanning) {
      handleScan();
    }
  }, [handleScan, isScanning]);

  return (
    <div className="input-container">
      <h2 className="title">GitHub Power Scouter</h2>
      
      <div className="scan-section">
        <input
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="GitHub Username"
          className="username-input"
          disabled={isScanning}
        />
        <button
          onClick={handleScan}
          disabled={isScanning || !username.trim()}
          className="scan-button"
        >
          {isScanning ? 'SCANNING...' : 'SCAN'}
        </button>
      </div>

      {error && (
        <div className="error-message" role="alert">
          {error}
        </div>
      )}

      <TokenSection
        token={token}
        onTokenChange={setToken}
        onSaveToken={handleSaveToken}
        onClearToken={() => TokenManager.clearToken()}
        tokenStatus={tokenStatus}
        rateLimit={rateLimit}
      />

      <button
        onClick={onShowResume}
        disabled={!hasDetailedData}
        className="detail-button"
        style={{ display: hasDetailedData ? 'block' : 'none' }}
      >
        技術履歴書を表示 / Show Tech Resume
      </button>
    </div>
  );
};

TokenSection サブコンポーネント

interface TokenSectionProps {
  token: string;
  onTokenChange: (token: string) => void;
  onSaveToken: () => void;
  onClearToken: () => void;
  tokenStatus: string;
  rateLimit: GitHubRateLimit | null;
}

const TokenSection: React.FC<TokenSectionProps> = ({
  token,
  onTokenChange,
  onSaveToken,
  onClearToken,
  tokenStatus,
  rateLimit
}) => {
  return (
    <div className="token-section">
      <div className="token-title">GITHUB TOKEN SETTINGS</div>
      
      <div className="token-controls">
        <input
          type="password"
          value={token}
          onChange={(e) => onTokenChange(e.target.value)}
          placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
          className="token-input"
        />
        <button onClick={onSaveToken} className="token-button">
          SAVE
        </button>
        <button onClick={onClearToken} className="token-button clear">
          CLEAR
        </button>
      </div>
      
      <div className="token-status" aria-live="polite">
        {tokenStatus}
      </div>
      
      <div className="token-info">
        <a
          href="https://github.com/settings/tokens/new?scopes=public_repo,read:user"
          target="_blank"
          rel="noopener noreferrer"
        >
          Generate token 
        </a>
        | Increases API limit from 60 to 5,000 requests/hour
      </div>
      
      {rateLimit && (
        <div className="rate-limit-info">
          API calls remaining: {rateLimit.rate.remaining}/{rateLimit.rate.limit}
        </div>
      )}
    </div>
  );
};

📊 ScouterDisplay コンポーネント

概要

某世界的漫画風のスカウター表示を担当するコンポーネント。SVGアニメーションとリアルタイム更新を実装。

Props

interface ScouterDisplayProps {
  isScanning: boolean;
  scanData: PowerLevelResult | null;
  username: string;
  onScanComplete: (data: PowerLevelResult, techData: DetailedTechData) => void;
  onScanError: (error: string) => void;
}

主要機能

  • リアルタイムスキャンアニメーション
  • パワーレベル表示
  • 統計情報表示
  • プログレスバー
  • エラーハンドリング

実装例

const ScouterDisplay: React.FC<ScouterDisplayProps> = ({
  isScanning,
  scanData,
  username,
  onScanComplete,
  onScanError
}) => {
  const [progress, setProgress] = useState(0);
  const [currentPowerLevel, setCurrentPowerLevel] = useState(0);
  const [displayStats, setDisplayStats] = useState<PowerLevelResult | null>(null);
  const [showComplete, setShowComplete] = useState(false);
  const [loadingMessage, setLoadingMessage] = useState('');
  const animationRef = useRef<number | undefined>(undefined);

  // スキャン処理
  const performScan = useCallback(async () => {
    if (!username) return;
    
    try {
      setProgress(10);
      setLoadingMessage('スキャナーを初期化中...');
      
      const apiClient = new GitHubApiClient(TokenManager.getToken());
      
      // ユーザーデータ取得
      const userData = await apiClient.getUser(username);
      setProgress(30);
      setLoadingMessage('リポジトリを分析中...');
      
      // リポジトリデータ取得
      const repoData = await apiClient.getUserRepos(username);
      setProgress(50);
      setLoadingMessage('言語能力を測定中...');
      
      // 言語統計取得
      const languageStats = await new LanguageAnalyzer()
        .analyzeUserLanguages(repoData, apiClient);
      setProgress(70);
      setLoadingMessage('最近の活動を測定中...');
      
      // 活動データ取得
      const activityData = await new ActivityAnalyzer()
        .analyzeUserActivity(username, apiClient);
      setProgress(90);
      setLoadingMessage('技術スタックを分析中...');
      
      // 技術スタック分析
      const techStack = await new TechStackAnalyzer()
        .analyzeTechStack(repoData, apiClient);
      setProgress(100);
      setLoadingMessage('分析完了!');
      
      // パワーレベル計算
      const powerLevelResult = new PowerLevelCalculator()
        .calculateTotalPower(userData, repoData, languageStats, activityData);
      
      const detailedTechData: DetailedTechData = {
        username: userData.login,
        powerLevel: powerLevelResult.power,
        languages: languageStats,
        techStack,
        stats: {
          repos: userData.public_repos,
          stars: powerLevelResult.stats.stars,
          contributions: activityData.recentContributions
        }
      };
      
      setTimeout(() => {
        onScanComplete(powerLevelResult, detailedTechData);
      }, 1000);
      
    } catch (error) {
      console.error('Scan error:', error);
      onScanError(error instanceof Error ? error.message : 'Unknown error');
    }
  }, [username, onScanComplete, onScanError]);

  // スキャン開始時の処理
  useEffect(() => {
    if (isScanning && username) {
      performScan();
    }
  }, [isScanning, username, performScan]);

  // パワーレベルアニメーション
  const animateCounter = useCallback((target: number) => {
    const duration = 3000;
    const startTime = Date.now();
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      // イージング関数適用
      const easeProgress = easeInOutCubic(progress);
      const currentValue = Math.floor(target * easeProgress);
      
      // ランダム変動を追加
      const randomVariation = Math.floor(Math.random() * 10) - 5;
      const displayValue = Math.max(0, currentValue + randomVariation);
      
      setCurrentPowerLevel(displayValue);
      
      if (progress < 1) {
        animationRef.current = requestAnimationFrame(animate);
      } else {
        setCurrentPowerLevel(target);
        setShowComplete(true);
      }
    };
    
    animate();
  }, []);

  // データ更新時の処理
  useEffect(() => {
    if (scanData) {
      setDisplayStats(scanData);
      animateCounter(scanData.power);
    }
  }, [scanData, animateCounter]);

  return (
    <div className="scouter-container">
      <SVGDisplay
        username={username}
        currentPowerLevel={currentPowerLevel}
        isScanning={isScanning}
        progress={progress}
        showComplete={showComplete}
        displayStats={displayStats}
        loadingMessage={loadingMessage}
      />
    </div>
  );
};

SVGDisplay サブコンポーネント

interface SVGDisplayProps {
  username: string;
  currentPowerLevel: number;
  isScanning: boolean;
  progress: number;
  showComplete: boolean;
  displayStats: PowerLevelResult | null;
  loadingMessage: string;
}

const SVGDisplay: React.FC<SVGDisplayProps> = ({
  username,
  currentPowerLevel,
  isScanning,
  progress,
  showComplete,
  displayStats,
  loadingMessage
}) => {
  return (
    <svg width="500" height="300" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <GlowFilter />
        <ScanlineGradient />
      </defs>
      
      <BackgroundFrame />
      <ScanLines />
      
      <text x="30" y="40" className="username scouter-text">
        {username ? `TARGET: ${username.toUpperCase()}` : 'TARGET: '}
      </text>
      
      <text x="30" y="65" className="label scouter-text">
        {isScanning ? 'SCANNING TARGET...' : 'STANDBY'}
      </text>
      
      <text x="30" y="105" className="label scouter-text">
        POWER LEVEL:
      </text>
      
      <text x="30" y="145" className="power-level scouter-text">
        {currentPowerLevel.toLocaleString()}
      </text>
      
      {showComplete && (
        <text x="30" y="175" className="complete-msg scouter-text">
          SCAN COMPLETE
        </text>
      )}
      
      {displayStats && (
        <StatsGroup stats={displayStats} />
      )}
      
      <StatusIndicator />
      
      {isScanning && (
        <ProgressBar progress={progress} />
      )}
    </svg>
  );
};

📄 ResumeModal コンポーネント

概要

技術履歴書を表示するモーダルコンポーネント。詳細な技術スタックと実績を表示。

Props

interface ResumeModalProps {
  isOpen: boolean;
  onClose: () => void;
  techData: DetailedTechData | null;
}

主要機能

  • 技術スキル表示
  • プログラミング言語統計
  • フレームワーク・ライブラリ一覧
  • 開発実績表示
  • OSS貢献度表示

実装例

const ResumeModal: React.FC<ResumeModalProps> = ({ 
  isOpen, 
  onClose, 
  techData 
}) => {
  if (!isOpen || !techData) return null;

  const handleBackdropClick = useCallback((e: React.MouseEvent) => {
    if (e.target === e.currentTarget) {
      onClose();
    }
  }, [onClose]);

  // ESCキーでモーダルを閉じる
  useEffect(() => {
    const handleEscKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }
    };

    if (isOpen) {
      document.addEventListener('keydown', handleEscKey);
      document.body.style.overflow = 'hidden'; // スクロール防止
    }

    return () => {
      document.removeEventListener('keydown', handleEscKey);
      document.body.style.overflow = 'auto';
    };
  }, [isOpen, onClose]);

  return (
    <div className="resume-modal" onClick={handleBackdropClick}>
      <div className="resume-content" role="dialog" aria-modal="true">
        <ResumeHeader 
          username={techData.username}
          powerLevel={techData.powerLevel}
          onClose={onClose}
        />
        
        <LanguageSection languages={techData.languages} />
        
        <TechStackSection techStack={techData.techStack} />
        
        <FrameworkSection frameworks={techData.techStack.frameworks} />
        
        <AchievementSection stats={techData.stats} />
        
        <OSSContributionSection stats={techData.stats} />
      </div>
    </div>
  );
};

ResumeHeader サブコンポーネント

interface ResumeHeaderProps {
  username: string;
  powerLevel: number;
  onClose: () => void;
}

const ResumeHeader: React.FC<ResumeHeaderProps> = ({
  username,
  powerLevel,
  onClose
}) => {
  return (
    <div className="resume-header">
      <button
        className="close-button"
        onClick={onClose}
        aria-label="Close modal"
      >
        &times;
      </button>
      
      <h2 className="resume-title">
        技術スキル履歴書 / Technical Skills Resume
      </h2>
      
      <div className="resume-user-info">
        <h3>{username}</h3>
        <p>Power Level: {powerLevel.toLocaleString()}</p>
      </div>
    </div>
  );
};

LanguageSection サブコンポーネント

interface LanguageSectionProps {
  languages: ProcessedLanguage[];
}

const LanguageSection: React.FC<LanguageSectionProps> = ({ languages }) => {
  return (
    <div className="resume-section">
      <h3 className="section-title">
        プログラミング言語 / Programming Languages
      </h3>
      
      <div className="tech-grid">
        {languages.map((lang, index) => (
          <div key={index} className="tech-category">
            <div className="tech-category-title">{lang.name}</div>
            
            <div className="proficiency-bar">
              <div 
                className="proficiency-fill" 
                style={{ width: `${lang.percentage}%` }}
              />
            </div>
            
            <div className="tech-item">
              {lang.percentage}% of code
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

🎯 共通Hooks

useGitHubApi

const useGitHubApi = () => {
  const [token, setToken] = useState<string | null>(
    TokenManager.getToken()
  );
  
  const apiClient = useMemo(() => 
    new GitHubApiClient(token), [token]
  );
  
  const updateToken = useCallback((newToken: string | null) => {
    setToken(newToken);
    if (newToken) {
      TokenManager.saveToken(newToken);
    } else {
      TokenManager.clearToken();
    }
  }, []);
  
  return { apiClient, token, updateToken };
};

useAsyncState

interface AsyncState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

const useAsyncState = <T>(): [
  AsyncState<T>,
  (promise: Promise<T>) => Promise<void>
] => {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    loading: false,
    error: null,
  });
  
  const execute = useCallback(async (promise: Promise<T>) => {
    setState({ data: null, loading: true, error: null });
    
    try {
      const result = await promise;
      setState({ data: result, loading: false, error: null });
    } catch (error) {
      setState({ 
        data: null, 
        loading: false, 
        error: error instanceof Error ? error.message : 'Unknown error' 
      });
    }
  }, []);
  
  return [state, execute];
};

useDebounce

const useDebounce = <T>(value: T, delay: number): T => {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
};

🎨 アニメーションユーティリティ

イージング関数

export const easeInOutCubic = (t: number): number => {
  return t < 0.5 
    ? 4 * t * t * t 
    : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
};

export const easeOutBounce = (t: number): number => {
  const n1 = 7.5625;
  const d1 = 2.75;
  
  if (t < 1 / d1) {
    return n1 * t * t;
  } else if (t < 2 / d1) {
    return n1 * (t -= 1.5 / d1) * t + 0.75;
  } else if (t < 2.5 / d1) {
    return n1 * (t -= 2.25 / d1) * t + 0.9375;
  } else {
    return n1 * (t -= 2.625 / d1) * t + 0.984375;
  }
};

アニメーション制御

export class AnimationController {
  private animationId: number | null = null;
  
  start(callback: (progress: number) => void, duration: number) {
    const startTime = Date.now();
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      callback(progress);
      
      if (progress < 1) {
        this.animationId = requestAnimationFrame(animate);
      } else {
        this.animationId = null;
      }
    };
    
    animate();
  }
  
  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }
  
  isRunning(): boolean {
    return this.animationId !== null;
  }
}

📊 テスト例

コンポーネントテスト

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserInput } from '../UserInput';

describe('UserInput', () => {
  const mockOnScan = jest.fn();
  const mockOnShowResume = jest.fn();
  
  beforeEach(() => {
    mockOnScan.mockClear();
    mockOnShowResume.mockClear();
  });
  
  it('should render username input and scan button', () => {
    render(
      <UserInput 
        onScan={mockOnScan}
        isScanning={false}
        onShowResume={mockOnShowResume}
        hasDetailedData={false}
      />
    );
    
    expect(screen.getByPlaceholderText('GitHub Username')).toBeInTheDocument();
    expect(screen.getByText('SCAN')).toBeInTheDocument();
  });
  
  it('should call onScan when scan button is clicked', async () => {
    const user = userEvent.setup();
    
    render(
      <UserInput 
        onScan={mockOnScan}
        isScanning={false}
        onShowResume={mockOnShowResume}
        hasDetailedData={false}
      />
    );
    
    const input = screen.getByPlaceholderText('GitHub Username');
    const button = screen.getByText('SCAN');
    
    await user.type(input, 'testuser');
    await user.click(button);
    
    expect(mockOnScan).toHaveBeenCalledWith('testuser');
  });
  
  it('should disable scan button when scanning', () => {
    render(
      <UserInput 
        onScan={mockOnScan}
        isScanning={true}
        onShowResume={mockOnShowResume}
        hasDetailedData={false}
      />
    );
    
    const button = screen.getByText('SCANNING...');
    expect(button).toBeDisabled();
  });
});

🎯 これらのコンポーネントを組み合わせて、強力なGitHub Power Scouterを構築しましょう!

次はトラブルシューティングで問題解決の方法を確認できます。

Clone this wiki locally