| 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> | |
| ); | |
| } | |