import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; import { logStore } from '~/lib/stores/logs'; import { classNames } from '~/utils/classNames'; interface GitHubUserResponse { login: string; avatar_url: string; html_url: string; name: string; bio: string; public_repos: number; followers: number; following: number; created_at: string; public_gists: number; } interface GitHubRepoInfo { name: string; full_name: string; html_url: string; description: string; stargazers_count: number; forks_count: number; default_branch: string; updated_at: string; languages_url: string; } interface GitHubOrganization { login: string; avatar_url: string; html_url: string; } interface GitHubEvent { id: string; type: string; repo: { name: string; }; created_at: string; } interface GitHubLanguageStats { [language: string]: number; } interface GitHubStats { repos: GitHubRepoInfo[]; totalStars: number; totalForks: number; organizations: GitHubOrganization[]; recentActivity: GitHubEvent[]; languages: GitHubLanguageStats; totalGists: number; } interface GitHubConnection { user: GitHubUserResponse | null; token: string; tokenType: 'classic' | 'fine-grained'; stats?: GitHubStats; } export function GithubConnection() { const [connection, setConnection] = useState<GitHubConnection>({ user: null, token: '', tokenType: 'classic', }); const [isLoading, setIsLoading] = useState(true); const [isConnecting, setIsConnecting] = useState(false); const [isFetchingStats, setIsFetchingStats] = useState(false); const [isStatsExpanded, setIsStatsExpanded] = useState(false); const fetchGitHubStats = async (token: string) => { try { setIsFetchingStats(true); const reposResponse = await fetch( 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', { headers: { Authorization: `Bearer ${token}`, }, }, ); if (!reposResponse.ok) { throw new Error('Failed to fetch repositories'); } const repos = (await reposResponse.json()) as GitHubRepoInfo[]; const orgsResponse = await fetch('https://api.github.com/user/orgs', { headers: { Authorization: `Bearer ${token}`, }, }); if (!orgsResponse.ok) { throw new Error('Failed to fetch organizations'); } const organizations = (await orgsResponse.json()) as GitHubOrganization[]; const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { headers: { Authorization: `Bearer ${token}`, }, }); if (!eventsResponse.ok) { throw new Error('Failed to fetch events'); } const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); const languagePromises = repos.map((repo) => fetch(repo.languages_url, { headers: { Authorization: `Bearer ${token}`, }, }).then((res) => res.json() as Promise<Record<string, number>>), ); const repoLanguages = await Promise.all(languagePromises); const languages: GitHubLanguageStats = {}; repoLanguages.forEach((repoLang) => { Object.entries(repoLang).forEach(([lang, bytes]) => { languages[lang] = (languages[lang] || 0) + bytes; }); }); const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); const totalGists = connection.user?.public_gists || 0; setConnection((prev) => ({ ...prev, stats: { repos, totalStars, totalForks, organizations, recentActivity, languages, totalGists, }, })); } catch (error) { logStore.logError('Failed to fetch GitHub stats', { error }); toast.error('Failed to fetch GitHub statistics'); } finally { setIsFetchingStats(false); } }; useEffect(() => { const savedConnection = localStorage.getItem('github_connection'); if (savedConnection) { const parsed = JSON.parse(savedConnection); if (!parsed.tokenType) { parsed.tokenType = 'classic'; } setConnection(parsed); if (parsed.user && parsed.token) { fetchGitHubStats(parsed.token); } } setIsLoading(false); }, []); if (isLoading || isConnecting || isFetchingStats) { return <LoadingSpinner />; } const fetchGithubUser = async (token: string) => { try { setIsConnecting(true); const response = await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { throw new Error('Invalid token or unauthorized'); } const data = (await response.json()) as GitHubUserResponse; const newConnection: GitHubConnection = { user: data, token, tokenType: connection.tokenType, }; localStorage.setItem('github_connection', JSON.stringify(newConnection)); setConnection(newConnection); await fetchGitHubStats(token); toast.success('Successfully connected to GitHub'); } catch (error) { logStore.logError('Failed to authenticate with GitHub', { error }); toast.error('Failed to connect to GitHub'); setConnection({ user: null, token: '', tokenType: 'classic' }); } finally { setIsConnecting(false); } }; const handleConnect = async (event: React.FormEvent) => { event.preventDefault(); await fetchGithubUser(connection.token); }; const handleDisconnect = () => { localStorage.removeItem('github_connection'); setConnection({ user: null, token: '', tokenType: 'classic' }); toast.success('Disconnected from GitHub'); }; return ( <motion.div className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} > <div className="p-6 space-y-6"> <div className="flex items-center gap-2"> <div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" /> <h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3> </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div> <label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label> <select value={connection.tokenType} onChange={(e) => setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' })) } disabled={isConnecting || !!connection.user} className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-bolt-elements-textPrimary', 'focus:outline-none focus:ring-1 focus:ring-purple-500', 'disabled:opacity-50', )} > <option value="classic">Personal Access Token (Classic)</option> <option value="fine-grained">Fine-grained Token</option> </select> </div> <div> <label className="block text-sm text-bolt-elements-textSecondary mb-2"> {connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'} </label> <input type="password" value={connection.token} onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))} disabled={isConnecting || !!connection.user} placeholder={`Enter your GitHub ${ connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token' }`} className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-purple-500', 'disabled:opacity-50', )} /> <div className="mt-2 text-sm text-bolt-elements-textSecondary"> <a href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`} target="_blank" rel="noopener noreferrer" className="text-purple-500 hover:underline inline-flex items-center gap-1" > Get your token <div className="i-ph:arrow-square-out w-10 h-5" /> </a> <span className="mx-2">•</span> <span> Required scopes:{' '} {connection.tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'} </span> </div> </div> </div> <div className="flex items-center gap-3"> {!connection.user ? ( <button onClick={handleConnect} disabled={isConnecting || !connection.token} className={classNames( 'px-4 py-2 rounded-lg text-sm flex items-center gap-2', 'bg-purple-500 text-white', 'hover:bg-purple-600', 'disabled:opacity-50 disabled:cursor-not-allowed', )} > {isConnecting ? ( <> <div className="i-ph:spinner-gap animate-spin" /> Connecting... </> ) : ( <> <div className="i-ph:plug-charging w-4 h-4" /> Connect </> )} </button> ) : ( <button onClick={handleDisconnect} className={classNames( 'px-4 py-2 rounded-lg text-sm flex items-center gap-2', 'bg-red-500 text-white', 'hover:bg-red-600', )} > <div className="i-ph:plug-x w-4 h-4" /> Disconnect </button> )} {connection.user && ( <span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1"> <div className="i-ph:check-circle w-4 h-4" /> Connected to GitHub </span> )} </div> {connection.user && connection.stats && ( <div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6"> <button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent"> <div className="flex items-center gap-4"> <img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" /> <div className="flex-1"> <div className="flex items-center justify-between"> <h3 className="text-lg font-medium text-bolt-elements-textPrimary"> {connection.user.name || connection.user.login} </h3> <div className={classNames( 'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform', isStatsExpanded ? 'rotate-180' : '', )} /> </div> {connection.user.bio && ( <p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p> )} <div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary"> <span className="flex items-center gap-1"> <div className="i-ph:users w-4 h-4" /> {connection.user.followers} followers </span> <span className="flex items-center gap-1"> <div className="i-ph:book-bookmark w-4 h-4" /> {connection.user.public_repos} public repos </span> <span className="flex items-center gap-1"> <div className="i-ph:star w-4 h-4" /> {connection.stats.totalStars} stars </span> <span className="flex items-center gap-1"> <div className="i-ph:git-fork w-4 h-4" /> {connection.stats.totalForks} forks </span> </div> </div> </div> </button> {isStatsExpanded && ( <div className="pt-4"> {connection.stats.organizations.length > 0 && ( <div className="mb-6"> <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4> <div className="flex flex-wrap gap-3"> {connection.stats.organizations.map((org) => ( <a key={org.login} href={org.html_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors" > <img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" /> <span className="text-sm text-bolt-elements-textPrimary">{org.login}</span> </a> ))} </div> </div> )} {/* Languages Section */} <div className="mb-6"> <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4> <div className="flex flex-wrap gap-2"> {Object.entries(connection.stats.languages) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([language]) => ( <span key={language} className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20" > {language} </span> ))} </div> </div> {/* Recent Activity Section */} <div className="mb-6"> <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4> <div className="space-y-3"> {connection.stats.recentActivity.map((event) => ( <div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm"> <div className="flex items-center gap-2 text-bolt-elements-textPrimary"> <div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" /> <span className="font-medium">{event.type.replace('Event', '')}</span> <span>on</span> <a href={`https://github.com/${event.repo.name}`} target="_blank" rel="noopener noreferrer" className="text-purple-500 hover:underline" > {event.repo.name} </a> </div> <div className="mt-1 text-xs text-bolt-elements-textSecondary"> {new Date(event.created_at).toLocaleDateString()} at{' '} {new Date(event.created_at).toLocaleTimeString()} </div> </div> ))} </div> </div> {/* Additional Stats */} <div className="grid grid-cols-4 gap-4 mb-6"> <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]"> <div className="text-sm text-bolt-elements-textSecondary">Member Since</div> <div className="text-lg font-medium text-bolt-elements-textPrimary"> {new Date(connection.user.created_at).toLocaleDateString()} </div> </div> <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]"> <div className="text-sm text-bolt-elements-textSecondary">Public Gists</div> <div className="text-lg font-medium text-bolt-elements-textPrimary"> {connection.stats.totalGists} </div> </div> <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]"> <div className="text-sm text-bolt-elements-textSecondary">Organizations</div> <div className="text-lg font-medium text-bolt-elements-textPrimary"> {connection.stats.organizations.length} </div> </div> <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]"> <div className="text-sm text-bolt-elements-textSecondary">Languages</div> <div className="text-lg font-medium text-bolt-elements-textPrimary"> {Object.keys(connection.stats.languages).length} </div> </div> </div> {/* Repositories Section */} <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4> <div className="space-y-3"> {connection.stats.repos.map((repo) => ( <a key={repo.full_name} href={repo.html_url} target="_blank" rel="noopener noreferrer" className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors" > <div className="flex items-center justify-between"> <div> <h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"> <div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" /> {repo.name} </h5> {repo.description && ( <p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p> )} <div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary"> <span className="flex items-center gap-1"> <div className="i-ph:git-branch w-3 h-3" /> {repo.default_branch} </span> <span>•</span> <span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span> </div> </div> <div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary"> <span className="flex items-center gap-1"> <div className="i-ph:star w-3 h-3" /> {repo.stargazers_count} </span> <span className="flex items-center gap-1"> <div className="i-ph:git-fork w-3 h-3" /> {repo.forks_count} </span> </div> </div> </a> ))} </div> </div> )} </div> )} </div> </motion.div> ); } function LoadingSpinner() { return ( <div className="flex items-center justify-center p-4"> <div className="flex items-center gap-2"> <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" /> <span className="text-bolt-elements-textSecondary">Loading...</span> </div> </div> ); }