feat: gitLab Integration Implementation / github refactor / overal improvements (#1963)

* Add GitLab integration components

Introduced PushToGitLabDialog and GitlabConnection components to handle GitLab project connections and push functionality. Includes user authentication, project handling, and UI for seamless integration with GitLab.

* Add components for GitLab connection and push dialog

Introduce `GitlabConnection` and `PushToGitLabDialog` components to handle GitLab integration. These components allow users to connect their GitLab account, manage recent projects, and push code to a GitLab repository with detailed configurations and feedback.

* Fix GitLab personal access tokens link to use correct URL

* Update GitHub push call to use new pushToRepository method

* Enhance GitLab integration with performance improvements

- Add comprehensive caching system for repositories and user data
- Implement pagination and search/filter functionality with debouncing
- Add skeleton loaders and improved loading states
- Implement retry logic for API calls with exponential backoff
- Add background refresh capabilities
- Improve error handling and user feedback
- Optimize API calls to reduce loading times

* feat: implement GitLab integration with connection management and repository handling

- Add GitLab connection UI components
- Implement GitLab API service for repository operations
- Add GitLab connection store for state management
- Update existing connection components (Vercel, Netlify)
- Add repository listing and statistics display
- Refactor GitLab components into organized folder structure

* fix: resolve GitLab deployment issues and improve user experience

- Fix DialogTitle accessibility warnings for screen readers
- Remove CORS-problematic attributes from avatar images to prevent loading errors
- Enhance GitLab API error handling with detailed error messages
- Fix project creation settings to prevent initial commit conflicts
- Add automatic GitLab connection state initialization on app startup
- Improve deployment dialog UI with better error handling and user feedback
- Add GitLab deployment source type to action runner system
- Clean up deprecated push dialog files and consolidate deployment components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: implement GitHub clone repository dialog functionality

This commit fixes the missing GitHub repository selection dialog in the "Clone a repo" feature
by implementing the same elegant interface pattern used by GitLab.

Key Changes:
- Added onCloneRepository prop support to GitHubConnection component
- Updated RepositoryCard to generate proper GitHub clone URLs (https://github.com/{full_name}.git)
- Implemented full GitHub repository selection dialog in GitCloneButton.tsx
- Added proper dialog close handling after successful clone operations
- Maintained existing GitHub connection settings page functionality

Technical Details:
- Follows same component patterns as GitLab implementation
- Uses proper TypeScript interfaces for clone URL handling
- Includes professional dialog styling with loading states
- Supports repository search, pagination, and authentication flow

The GitHub clone experience now matches GitLab's functionality, providing users with
a unified and intuitive repository selection interface across both providers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Clean up unused connection components

- Remove ConnectionForm.tsx (unused GitHub form component)
- Remove CreateBranchDialog.tsx (unused branch creation dialog)
- Remove RepositoryDialogContext.tsx (unused context provider)
- Remove empty components/ directory

These files were not referenced anywhere in the codebase and were leftover from development.

* Remove environment variables info section from ConnectionsTab

- Remove collapsible environment variables section
- Clean up unused state and imports
- Simplify the connections tab UI

* Reorganize connections folder structure

- Create netlify/ folder and move NetlifyConnection.tsx
- Create vercel/ folder and move VercelConnection.tsx
- Add index.ts files for both netlify and vercel folders
- Update imports in ConnectionsTab.tsx to use new folder structure
- All connection components now follow consistent folder organization

---------

Co-authored-by: Hayat Bourgi <hayat.bourgi@montyholding.com>
Co-authored-by: Hayat55 <53140162+Hayat55@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Stijnus
2025-09-05 14:01:33 +02:00
committed by GitHub
parent 8a685603be
commit 3ea96506ea
46 changed files with 4401 additions and 4025 deletions

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { Button } from '~/components/ui/Button';
import { githubConnectionStore } from '~/lib/stores/githubConnection';
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
toast.error('Please enter a valid GitHub token');
return;
}
setIsSubmitting(true);
try {
await githubConnectionStore.connect(token.trim(), tokenType);
toast.success('Successfully connected to GitHub!');
onClose();
setToken('');
} catch (error) {
console.error('GitHub connection failed:', error);
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setToken('');
onClose();
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={handleClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor shadow-xl z-50"
>
<div className="p-6">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">
Connect to GitHub
</Dialog.Title>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">Token Type</label>
<div className="flex gap-2">
<label className="flex items-center">
<input
type="radio"
value="classic"
checked={tokenType === 'classic'}
onChange={(e) => setTokenType(e.target.value as 'classic')}
className="mr-2"
/>
<span className="text-sm text-bolt-elements-textSecondary">Classic Token</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="fine-grained"
checked={tokenType === 'fine-grained'}
onChange={(e) => setTokenType(e.target.value as 'fine-grained')}
className="mr-2"
/>
<span className="text-sm text-bolt-elements-textSecondary">Fine-grained Token</span>
</label>
</div>
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textPrimary mb-2">
GitHub Personal Access Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
disabled={isSubmitting}
autoComplete="off"
/>
</div>
<div className="bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-md p-4">
<div className="flex items-start gap-3">
<div className="i-ph:info w-5 h-5 text-bolt-elements-icon-info mt-0.5 flex-shrink-0" />
<div className="text-sm text-bolt-elements-textSecondary space-y-2">
<p>To create a GitHub Personal Access Token:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Go to GitHub Settings Developer settings Personal access tokens</li>
<li>Click "Generate new token"</li>
<li>Select appropriate scopes (repo, user, etc.)</li>
<li>Copy and paste the token here</li>
</ol>
<p className="text-xs">
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-textAccent hover:underline"
>
Learn more about creating tokens
</a>
</p>
</div>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" disabled={!token.trim() || isSubmitting} className="flex-1">
{isSubmitting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</form>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,276 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { Button } from '~/components/ui/Button';
import {
githubConnectionAtom,
githubConnectionStore,
isGitHubConnected,
isGitHubConnecting,
isGitHubLoadingStats,
} from '~/lib/stores/githubConnection';
import { AuthDialog } from './AuthDialog';
import { StatsDisplay } from './StatsDisplay';
import { RepositoryList } from './RepositoryList';
interface GitHubConnectionProps {
onCloneRepository?: (repoUrl: string) => void;
}
export default function GitHubConnection({ onCloneRepository }: GitHubConnectionProps = {}) {
const connection = useStore(githubConnectionAtom);
const isConnected = useStore(isGitHubConnected);
const isConnecting = useStore(isGitHubConnecting);
const isLoadingStats = useStore(isGitHubLoadingStats);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const [isReposExpanded, setIsReposExpanded] = useState(false);
const handleConnect = () => {
setIsAuthDialogOpen(true);
};
const handleDisconnect = () => {
githubConnectionStore.disconnect();
setIsStatsExpanded(false);
setIsReposExpanded(false);
toast.success('Disconnected from GitHub');
};
const handleRefreshStats = async () => {
try {
await githubConnectionStore.fetchStats();
toast.success('GitHub stats refreshed');
} catch (error) {
toast.error(`Failed to refresh stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTokenTypeChange = (tokenType: 'classic' | 'fine-grained') => {
githubConnectionStore.updateTokenType(tokenType);
};
const handleCloneRepository = (repoUrl: string) => {
if (onCloneRepository) {
onCloneRepository(repoUrl);
} else {
window.open(repoUrl, '_blank');
}
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor">
<div className="i-ph:git-repository text-bolt-elements-icon-primary w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">GitHub</h3>
<p className="text-sm text-bolt-elements-textSecondary">
{isConnected
? `Connected as ${connection.user?.login}`
: 'Connect your GitHub account to manage repositories'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Button
onClick={handleRefreshStats}
disabled={isLoadingStats}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isLoadingStats ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Refreshing...
</>
) : (
<>
<div className="i-ph:arrows-clockwise w-4 h-4" />
Refresh Stats
</>
)}
</Button>
<Button
onClick={handleDisconnect}
variant="outline"
size="sm"
className="text-bolt-elements-textDanger hover:text-bolt-elements-textDanger"
>
<div className="i-ph:sign-out w-4 h-4 mr-2" />
Disconnect
</Button>
</>
) : (
<Button
onClick={handleConnect}
disabled={isConnecting}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plus w-4 h-4" />
Connect
</>
)}
</Button>
)}
</div>
</div>
{/* Connection Status */}
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor">
<div className="flex items-center gap-3">
<div
className={classNames(
'w-3 h-3 rounded-full',
isConnected ? 'bg-bolt-elements-icon-success' : 'bg-bolt-elements-icon-secondary',
)}
/>
<span className="text-sm font-medium text-bolt-elements-textPrimary">
{isConnected ? 'Connected' : 'Not Connected'}
</span>
{connection.rateLimit && (
<span className="text-xs text-bolt-elements-textSecondary ml-auto">
Rate limit: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
</span>
)}
</div>
{/* Token Type Selection */}
{isConnected && (
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor">
<label className="block text-xs font-medium text-bolt-elements-textPrimary mb-2">Token Type</label>
<div className="flex gap-3">
{(['classic', 'fine-grained'] as const).map((type) => (
<label key={type} className="flex items-center cursor-pointer">
<input
type="radio"
value={type}
checked={connection.tokenType === type}
onChange={() => handleTokenTypeChange(type)}
className="mr-2 text-bolt-elements-item-contentAccent focus:ring-bolt-elements-item-contentAccent"
/>
<span className="text-xs text-bolt-elements-textSecondary capitalize">
{type.replace('-', ' ')} Token
</span>
</label>
))}
</div>
</div>
)}
</div>
{/* User Profile */}
{isConnected && connection.user && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor"
>
<div className="flex items-center gap-4">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent"
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user.name || connection.user.login}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
{connection.user.bio && (
<p className="text-xs text-bolt-elements-textTertiary mt-1 line-clamp-2">{connection.user.bio}</p>
)}
</div>
<div className="text-right">
<div className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user.public_repos?.toLocaleString() || 0}
</div>
<div className="text-xs text-bolt-elements-textSecondary">repositories</div>
</div>
</div>
</motion.div>
)}
{/* Stats Section */}
{isConnected && connection.stats && (
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Stats</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-4 p-4 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor">
<StatsDisplay stats={connection.stats} onRefresh={handleRefreshStats} isRefreshing={isLoadingStats} />
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Repositories Section */}
{isConnected && connection.stats?.repos && connection.stats.repos.length > 0 && (
<Collapsible open={isReposExpanded} onOpenChange={setIsReposExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({connection.stats.repos.length})
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isReposExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-4 p-4 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor">
<RepositoryList
repositories={connection.stats.repos}
onClone={handleCloneRepository}
onRefresh={handleRefreshStats}
isRefreshing={isLoadingStats}
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Auth Dialog */}
<AuthDialog isOpen={isAuthDialogOpen} onClose={() => setIsAuthDialogOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface RepositoryCardProps {
repo: GitHubRepoInfo;
onClone?: (repoUrl: string) => void;
}
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`i-ph:${repo.private ? 'lock' : 'git-repository'} w-4 h-4 text-bolt-elements-icon-info`} />
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
{repo.private && (
<span className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor">
Private
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
{repo.topics && repo.topics.length > 0 && (
<div className="flex flex-wrap gap-1">
{repo.topics.slice(0, 3).map((topic) => (
<span
key={topic}
className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor"
>
{topic}
</span>
))}
{repo.topics.length > 3 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary border border-bolt-elements-borderColor">
+{repo.topics.length - 3}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
{repo.language && (
<span className="flex items-center gap-1" title="Primary Language">
<div className="i-ph:circle-fill w-2 h-2 text-bolt-elements-icon-success" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<div className="flex items-center gap-2 ml-auto">
{onClone && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const cloneUrl = `https://github.com/${repo.full_name}.git`;
onClone(cloneUrl);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
title="Clone repository"
>
<div className="i-ph:git-branch w-3.5 h-3.5" />
Clone
</button>
)}
<span className="flex items-center gap-1 group-hover:text-bolt-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</div>
</a>
);
}

View File

@@ -0,0 +1,144 @@
import React, { useState, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { RepositoryCard } from './RepositoryCard';
import type { GitHubRepoInfo } from '~/types/GitHub';
interface RepositoryListProps {
repositories: GitHubRepoInfo[];
onClone?: (repoUrl: string) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
const MAX_REPOS_PER_PAGE = 20;
export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isSearching, setIsSearching] = useState(false);
const filteredRepositories = useMemo(() => {
if (!searchQuery) {
return repositories;
}
setIsSearching(true);
const filtered = repositories.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.language && repo.language.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.topics && repo.topics.some((topic) => topic.toLowerCase().includes(searchQuery.toLowerCase()))),
);
setIsSearching(false);
return filtered;
}, [repositories, searchQuery]);
const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
const endIndex = startIndex + MAX_REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
const handleSearch = (query: string) => {
setSearchQuery(query);
setCurrentPage(1); // Reset to first page when searching
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
Repositories ({filteredRepositories.length})
</h4>
{onRefresh && (
<Button
onClick={onRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{isRefreshing ? (
<div className="i-ph:spinner animate-spin w-4 h-4" />
) : (
<div className="i-ph:arrows-clockwise w-4 h-4" />
)}
Refresh
</Button>
)}
</div>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search repositories by name, description, language, or topics..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<div className="i-ph:spinner animate-spin w-4 h-4 text-bolt-elements-textSecondary" />
) : (
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textSecondary" />
)}
</div>
</div>
{/* Repository Grid */}
<div className="space-y-4">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8 text-bolt-elements-textSecondary">
{searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} onClone={onClone} />
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
<div className="text-sm text-bolt-elements-textSecondary">
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
{Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
<div className="i-ph:caret-left w-4 h-4" />
Previous
</Button>
<span className="text-sm text-bolt-elements-textSecondary px-3">
{currentPage} of {totalPages}
</span>
<Button
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
<div className="i-ph:caret-right w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import type { GitHubStats } from '~/types/GitHub';
interface StatsDisplayProps {
stats: GitHubStats;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
// Calculate top languages for display
const topLanguages = Object.entries(stats.languages || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
return (
<div className="space-y-4">
{/* Repository Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: stats.publicRepos || 0,
},
{
label: 'Private Repos',
value: stats.privateRepos || 0,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Contribution Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: stats.totalStars || stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: stats.totalForks || stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-bolt-elements-icon-info',
},
{
label: 'Followers',
value: stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-bolt-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
{stat.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{/* Gist Stats */}
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Gist Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Gists',
value: stats.publicGists || 0,
icon: 'i-ph:note',
},
{
label: 'Total Gists',
value: stats.totalGists || 0,
icon: 'i-ph:note-blank',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
>
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} w-4 h-4 text-bolt-elements-icon-tertiary`} />
{stat.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{/* Top Languages */}
{topLanguages.length > 0 && (
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Top Languages</h5>
<div className="space-y-2">
{topLanguages.map(([language, count]) => (
<div key={language} className="flex items-center justify-between">
<span className="text-sm text-bolt-elements-textPrimary">{language}</span>
<span className="text-sm text-bolt-elements-textSecondary">{count} repositories</span>
</div>
))}
</div>
</div>
)}
{/* Recent Activity */}
{stats.recentActivity && stats.recentActivity.length > 0 && (
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Activity</h5>
<div className="space-y-2">
{stats.recentActivity.slice(0, 3).map((activity) => (
<div key={activity.id} className="flex items-center gap-2 text-sm">
<div className="i-ph:git-commit w-3 h-3 text-bolt-elements-icon-tertiary" />
<span className="text-bolt-elements-textSecondary">
{activity.type.replace('Event', '')} in {activity.repo.name}
</span>
<span className="text-xs text-bolt-elements-textTertiary ml-auto">
{new Date(activity.created_at).toLocaleDateString()}
</span>
</div>
))}
</div>
</div>
)}
<div className="pt-2 border-t border-bolt-elements-borderColor">
<div className="flex items-center justify-between">
<span className="text-xs text-bolt-elements-textSecondary">
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
</span>
{onRefresh && (
<Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm" className="text-xs">
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { default as GitHubConnection } from './GitHubConnection';
export { RepositoryCard } from './RepositoryCard';
export { RepositoryList } from './RepositoryList';
export { StatsDisplay } from './StatsDisplay';
export { AuthDialog } from './AuthDialog';