-
Notifications
You must be signed in to change notification settings - Fork 0
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
アプリケーションのルートコンポーネント。全体の状態管理とコンポーネント間の連携を担当します。
// 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>
);
};ユーザー入力フォームとGitHubトークン管理を担当するコンポーネント。
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>
);
};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>
);
};某世界的漫画風のスカウター表示を担当するコンポーネント。SVGアニメーションとリアルタイム更新を実装。
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>
);
};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>
);
};技術履歴書を表示するモーダルコンポーネント。詳細な技術スタックと実績を表示。
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>
);
};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"
>
×
</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>
);
};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>
);
};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 };
};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];
};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を構築しましょう!
次はトラブルシューティングで問題解決の方法を確認できます。