feat: comprehensive service integration refactor with enhanced tabs architecture (#1978)

* feat: add service tabs refactor with GitHub, GitLab, Supabase, Vercel, and Netlify integration

This commit introduces a comprehensive refactor of the connections system,
replacing the single connections tab with dedicated service integration tabs:

 New Service Tabs:
- GitHub Tab: Complete integration with repository management, stats, and API
- GitLab Tab: GitLab project integration and management
- Supabase Tab: Database project management with comprehensive analytics
- Vercel Tab: Project deployment management and monitoring
- Netlify Tab: Site deployment and build management

🔧 Supporting Infrastructure:
- Enhanced store management for each service with auto-connect via env vars
- API routes for secure server-side token handling and data fetching
- Updated TypeScript types with missing properties and interfaces
- Comprehensive hooks for service connections and state management
- Security utilities for API endpoint validation

🎨 UI/UX Improvements:
- Individual service tabs with tailored functionality
- Motion animations and improved loading states
- Connection testing and health monitoring
- Advanced analytics dashboards for each service
- Consistent design patterns across all service tabs

🛠️ Technical Changes:
- Removed legacy connection tab in favor of individual service tabs
- Updated tab configuration and routing system
- Added comprehensive error handling and loading states
- Enhanced type safety with extended interfaces
- Implemented environment variable auto-connection features

Note: Some TypeScript errors remain and will need to be resolved in follow-up commits.
The dev server runs successfully and the service tabs are functional.

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

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

* feat: comprehensive service integration refactor with enhanced tabs architecture

Major architectural improvements to service integrations:

**Service Integration Refactor:**
- Complete restructure of service connection tabs (GitHub, GitLab, Vercel, Netlify, Supabase)
- Migrated from centralized ConnectionsTab to dedicated service-specific tabs
- Added shared service integration components for consistent UX
- Implemented auto-connection feature using environment variables

**New Components & Architecture:**
- ServiceIntegrationLayout for consistent service tab structure
- ConnectionStatus, ServiceCard components for reusable UI patterns
- BranchSelector component for repository branch management
- Enhanced authentication dialogs with improved error handling

**API & Backend Enhancements:**
- New API endpoints: github-branches, gitlab-branches, gitlab-projects, vercel-user
- Enhanced GitLab API service with comprehensive project management
- Improved connection testing hooks (useConnectionTest)
- Better error handling and rate limiting across all services

**Configuration & Environment:**
- Updated .env.example with comprehensive service integration guides
- Added auto-connection support for all major services
- Improved development and production environment configurations
- Enhanced tab management with proper service icons

**Code Quality & TypeScript:**
- Fixed all TypeScript errors across service integration components
- Enhanced type definitions for Vercel, Supabase, and other service integrations
- Improved type safety with proper optional chaining and type assertions
- Better separation of concerns between UI and business logic

**Removed Legacy Code:**
- Removed redundant connection components and consolidated into service tabs
- Cleaned up unused imports and deprecated connection patterns
- Streamlined authentication flows across all services

This refactor provides a more maintainable, scalable architecture for service integrations
while significantly improving the user experience for managing external connections.

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

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

* refactor: clean up dead code and consolidate utilities

- Remove legacy .eslintrc.json (replaced by flat config)
- Remove duplicate app/utils/types.ts (unused type definitions)
- Remove app/utils/cn.ts and consolidate with classNames utility
- Clean up unused ServiceErrorHandler class implementation
- Enhance classNames utility to support boolean values
- Update GlowingEffect.tsx to use consolidated classNames utility

Removes ~150+ lines of unused code while maintaining all functionality.

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

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

* Simplify terminal health checks and improve project setup

Removed aggressive health checking and reconnection logic from TerminalManager to prevent issues with terminal responsiveness. Updated TerminalTabs to remove onReconnect handlers. Enhanced projectCommands utility to generate non-interactive setup commands and detect shadcn projects, improving automation and reliability of project setup.

* fix: resolve GitLab deployment issues and enhance GitHub deployment reliability

GitLab Deployment Fixes:
- Fix COEP header issue for avatar images by adding crossOrigin and referrerPolicy attributes
- Implement repository name sanitization to handle special characters and ensure GitLab compliance
- Enhance error handling with detailed validation error parsing and user-friendly messages
- Add explicit path field and description to project creation requests
- Improve URL encoding and project path resolution for proper API calls
- Add graceful file commit handling with timeout and error recovery

GitHub Deployment Enhancements:
- Add comprehensive repository name validation and sanitization
- Implement real-time feedback for invalid characters in repository name input
- Enhance error handling with specific error types and retry suggestions
- Improve user experience with better error messages and validation feedback
- Add repository name length limits and character restrictions
- Show sanitized name preview to users before submission

General Improvements:
- Add GitLabAuthDialog component for improved authentication flow
- Enhance logging and debugging capabilities for deployment operations
- Improve accessibility with proper dialog titles and descriptions
- Add better user notifications for name sanitization and validation issues

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Stijnus
2025-09-08 19:29:12 +02:00
committed by GitHub
parent 2fde6f8081
commit 4ca535b9d1
94 changed files with 12201 additions and 2986 deletions

View File

@@ -0,0 +1,305 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { useGitLabConnection } from '~/lib/hooks';
import GitLabConnection from './components/GitLabConnection';
import { StatsDisplay } from './components/StatsDisplay';
import { RepositoryList } from './components/RepositoryList';
// GitLab logo SVG component
const GitLabLogo = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
);
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
export default function GitLabTab() {
const { connection, isConnected, isLoading, error, testConnection, refreshStats } = useGitLabConnection();
const [connectionTest, setConnectionTest] = useState<ConnectionTestResult | null>(null);
const [isRefreshingStats, setIsRefreshingStats] = useState(false);
const handleTestConnection = async () => {
if (!connection?.user) {
setConnectionTest({
status: 'error',
message: 'No connection established',
timestamp: Date.now(),
});
return;
}
setConnectionTest({
status: 'testing',
message: 'Testing connection...',
});
try {
const isValid = await testConnection();
if (isValid) {
setConnectionTest({
status: 'success',
message: `Connected successfully as ${connection.user.username}`,
timestamp: Date.now(),
});
} else {
setConnectionTest({
status: 'error',
message: 'Connection test failed',
timestamp: Date.now(),
});
}
} catch (error) {
setConnectionTest({
status: 'error',
message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: Date.now(),
});
}
};
// Loading state for initial connection check
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<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>
</div>
);
}
// Error state for connection issues
if (error && !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<div className="text-sm text-red-600 dark:text-red-400 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
{error}
</div>
</div>
);
}
// Not connected state
if (!isConnected || !connection) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
</div>
<p className="text-sm text-bolt-elements-textSecondary">
Connect your GitLab account to enable advanced repository management features, statistics, and seamless
integration.
</p>
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<motion.div
className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-2">
<GitLabLogo />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitLab Integration
</h2>
</div>
<div className="flex items-center gap-2">
{connection?.rateLimit && (
<div className="flex items-center gap-2 px-3 py-1 bg-bolt-elements-background-depth-1 rounded-lg text-xs">
<div className="i-ph:cloud w-4 h-4 text-bolt-elements-textSecondary" />
<span className="text-bolt-elements-textSecondary">
API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
</span>
</div>
)}
</div>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your GitLab integration with advanced repository features and comprehensive statistics
</p>
{/* Connection Test Results */}
{connectionTest && (
<div
className={`p-3 rounded-lg border ${
connectionTest.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: connectionTest.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-4 h-4 ${
connectionTest.status === 'success'
? 'text-green-600'
: connectionTest.status === 'error'
? 'text-red-600'
: 'text-blue-600'
}`}
>
{connectionTest.status === 'success' ? (
<div className="i-ph:check-circle" />
) : connectionTest.status === 'error' ? (
<div className="i-ph:x-circle" />
) : (
<div className="i-ph:spinner animate-spin" />
)}
</div>
<span
className={`text-sm ${
connectionTest.status === 'success'
? 'text-green-800 dark:text-green-200'
: connectionTest.status === 'error'
? 'text-red-800 dark:text-red-200'
: 'text-blue-800 dark:text-blue-200'
}`}
>
{connectionTest.message}
</span>
</div>
</div>
)}
{/* GitLab Connection Component */}
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
{/* User Profile Section */}
{connection?.user && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 rounded-lg">
<div className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent flex items-center justify-center bg-bolt-elements-background-depth-2 overflow-hidden">
{connection.user.avatar_url &&
connection.user.avatar_url !== 'null' &&
connection.user.avatar_url !== '' ? (
<img
src={connection.user.avatar_url}
alt={connection.user.username}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML = (connection.user?.name || connection.user?.username || 'U')
.charAt(0)
.toUpperCase();
parent.classList.add(
'text-white',
'font-semibold',
'text-sm',
'flex',
'items-center',
'justify-center',
);
}
}}
/>
) : (
<div className="w-full h-full rounded-full bg-bolt-elements-item-contentAccent flex items-center justify-center text-white font-semibold text-sm">
{(connection.user?.name || connection.user?.username || 'U').charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.name || connection.user?.username}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user?.username}</p>
</div>
</div>
</motion.div>
)}
{/* GitLab Stats Section */}
{connection?.stats && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<h3 className="text-base font-medium text-bolt-elements-textPrimary mb-4">Statistics</h3>
<StatsDisplay
stats={connection.stats}
onRefresh={async () => {
setIsRefreshingStats(true);
try {
await refreshStats();
} catch (error) {
console.error('Failed to refresh stats:', error);
} finally {
setIsRefreshingStats(false);
}
}}
isRefreshing={isRefreshingStats}
/>
</motion.div>
)}
{/* GitLab Repositories Section */}
{connection?.stats?.projects && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="border-t border-bolt-elements-borderColor pt-6"
>
<RepositoryList
repositories={connection.stats.projects}
onRefresh={async () => {
setIsRefreshingStats(true);
try {
await refreshStats();
} catch (error) {
console.error('Failed to refresh repositories:', error);
} finally {
setIsRefreshingStats(false);
}
}}
isRefreshing={isRefreshingStats}
/>
</motion.div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { useGitLabConnection } from '~/lib/hooks';
interface GitLabAuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function GitLabAuthDialog({ isOpen, onClose }: GitLabAuthDialogProps) {
const { isConnecting, error, connect } = useGitLabConnection();
const [token, setToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
if (!token.trim()) {
toast.error('Please enter your GitLab access token');
return;
}
try {
await connect(token, gitlabUrl);
toast.success('Successfully connected to GitLab!');
setToken('');
onClose();
} catch (error) {
// Error handling is done in the hook
console.error('GitLab connect failed:', error);
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[10000]" />
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px]"
>
<Dialog.Content
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
aria-describedby="gitlab-auth-description"
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-4">
Connect to GitLab
</Dialog.Title>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 flex items-center justify-center text-orange-500">
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
</div>
<div>
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
GitLab Connection
</h3>
<p
id="gitlab-auth-description"
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
>
Connect your GitLab account to deploy your projects
</p>
</div>
</div>
<form onSubmit={handleConnect} className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
GitLab URL
</label>
<input
type="url"
value={gitlabUrl}
onChange={(e) => setGitlabUrl(e.target.value)}
disabled={isConnecting}
placeholder="https://gitlab.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
'focus:outline-none focus:ring-2 focus:ring-orange-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
/>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting}
placeholder="Enter your GitLab access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
'focus:outline-none focus:ring-2 focus:ring-orange-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
required
/>
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
<a
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-600 hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-3 h-3" />
</a>
<span className="mx-2"></span>
<span>Required scopes: api, read_repository</span>
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<motion.button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isConnecting}
>
Cancel
</motion.button>
<motion.button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm inline-flex items-center gap-2',
'bg-orange-500 text-white hover:bg-orange-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
whileHover={!isConnecting && token.trim() ? { scale: 1.02 } : {}}
whileTap={!isConnecting && token.trim() ? { scale: 0.98 } : {}}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect to GitLab
</>
)}
</motion.button>
</div>
</form>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,253 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import { useGitLabConnection } from '~/lib/hooks';
interface ConnectionTestResult {
status: 'success' | 'error' | 'testing';
message: string;
timestamp?: number;
}
interface GitLabConnectionProps {
connectionTest: ConnectionTestResult | null;
onTestConnection: () => void;
}
export default function GitLabConnection({ connectionTest, onTestConnection }: GitLabConnectionProps) {
const { isConnected, isConnecting, connection, error, connect, disconnect } = useGitLabConnection();
const [token, setToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
console.log('GitLab connect attempt:', {
token: token ? `${token.substring(0, 10)}...` : 'empty',
gitlabUrl,
tokenLength: token.length,
});
if (!token.trim()) {
console.log('Token is empty, not attempting connection');
return;
}
try {
console.log('Calling connect function...');
await connect(token, gitlabUrl);
console.log('Connect function completed successfully');
setToken(''); // Clear token on successful connection
} catch (error) {
console.error('GitLab connect failed:', error);
// Error handling is done in the hook
}
};
const handleDisconnect = () => {
disconnect();
toast.success('Disconnected from GitLab');
};
return (
<motion.div
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg"
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 justify-between">
<div className="flex items-center gap-2">
<div className="w-5 h-5 text-orange-600">
<svg viewBox="0 0 24 24" className="w-5 h-5">
<path
fill="currentColor"
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
/>
</svg>
</div>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitLab Connection</h3>
</div>
</div>
{!isConnected && (
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
<p className="flex items-center gap-1 mb-1">
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
<span className="font-medium">Tip:</span> You can also set the{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">VITE_GITLAB_ACCESS_TOKEN</code>{' '}
environment variable to connect automatically.
</p>
<p>
For self-hosted GitLab instances, also set{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">
VITE_GITLAB_URL=https://your-gitlab-instance.com
</code>
</p>
</div>
)}
<form onSubmit={handleConnect}>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitLab URL</label>
<input
type="text"
value={gitlabUrl}
onChange={(e) => setGitlabUrl(e.target.value)}
disabled={isConnecting || isConnected}
placeholder="https://gitlab.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={isConnecting || isConnected}
placeholder="Enter your GitLab access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
<span className="mx-2"></span>
<span>Required scopes: api, read_repository</span>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="flex items-center justify-between">
{!isConnected ? (
<>
<button
type="submit"
disabled={isConnecting || !token.trim()}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#FC6D26] text-white',
'hover:bg-[#E24329] hover:text-white',
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
'transform active:scale-95',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
<button
type="button"
onClick={() =>
console.log('Manual test:', { token: token ? `${token.substring(0, 10)}...` : 'empty', gitlabUrl })
}
className="px-4 py-2 rounded-lg text-sm bg-gray-500 text-white hover:bg-gray-600"
>
Test Values
</button>
</>
) : (
<>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<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 w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to GitLab
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
window.open(
`${connection?.gitlabUrl || 'https://gitlab.com'}/dashboard`,
'_blank',
'noopener,noreferrer',
)
}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:layout w-4 h-4" />
Dashboard
</Button>
<Button
onClick={onTestConnection}
disabled={connectionTest?.status === 'testing'}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{connectionTest?.status === 'testing' ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Testing...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Test Connection
</>
)}
</Button>
</div>
</div>
</>
)}
</div>
</form>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Button } from '~/components/ui/Button';
import { BranchSelector } from '~/components/ui/BranchSelector';
import { RepositoryCard } from './RepositoryCard';
import type { GitLabProjectInfo } from '~/types/GitLab';
import { useGitLabConnection } from '~/lib/hooks';
import { classNames } from '~/utils/classNames';
import { Search, RefreshCw, GitBranch, Calendar, Filter } from 'lucide-react';
interface GitLabRepositorySelectorProps {
onClone?: (repoUrl: string, branch?: string) => void;
className?: string;
}
type SortOption = 'updated' | 'stars' | 'name' | 'created';
type FilterOption = 'all' | 'owned' | 'member';
export function GitLabRepositorySelector({ onClone, className }: GitLabRepositorySelectorProps) {
const { connection, isConnected } = useGitLabConnection();
const [repositories, setRepositories] = useState<GitLabProjectInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('updated');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<GitLabProjectInfo | null>(null);
const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
const REPOS_PER_PAGE = 12;
// Fetch repositories
const fetchRepositories = async (refresh = false) => {
if (!isConnected || !connection?.token) {
return;
}
const loadingState = refresh ? setIsRefreshing : setIsLoading;
loadingState(true);
setError(null);
try {
const response = await fetch('/api/gitlab-projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: connection.token,
gitlabUrl: connection.gitlabUrl || 'https://gitlab.com',
}),
});
if (!response.ok) {
const errorData: any = await response.json().catch(() => ({ error: 'Failed to fetch repositories' }));
throw new Error(errorData.error || 'Failed to fetch repositories');
}
const data: any = await response.json();
setRepositories(data.projects || []);
} catch (err) {
console.error('Failed to fetch GitLab repositories:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
// Fallback to empty array on error
setRepositories([]);
} finally {
loadingState(false);
}
};
// Filter and search repositories
const filteredRepositories = useMemo(() => {
if (!repositories) {
return [];
}
const filtered = repositories.filter((repo: GitLabProjectInfo) => {
// Search filter
const matchesSearch =
!searchQuery ||
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase());
// Type filter
let matchesFilter = true;
switch (filterBy) {
case 'owned':
// This would need owner information from the API response
matchesFilter = true; // For now, show all
break;
case 'member':
// This would need member information from the API response
matchesFilter = true; // For now, show all
break;
case 'all':
default:
matchesFilter = true;
break;
}
return matchesSearch && matchesFilter;
});
// Sort repositories
filtered.sort((a: GitLabProjectInfo, b: GitLabProjectInfo) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'stars':
return b.star_count - a.star_count;
case 'created':
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); // Using updated_at as proxy
case 'updated':
default:
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
}
});
return filtered;
}, [repositories, searchQuery, sortBy, filterBy]);
// Pagination
const totalPages = Math.ceil(filteredRepositories.length / REPOS_PER_PAGE);
const startIndex = (currentPage - 1) * REPOS_PER_PAGE;
const currentRepositories = filteredRepositories.slice(startIndex, startIndex + REPOS_PER_PAGE);
const handleRefresh = () => {
fetchRepositories(true);
};
const handleCloneRepository = (repo: GitLabProjectInfo) => {
setSelectedRepo(repo);
setIsBranchSelectorOpen(true);
};
const handleBranchSelect = (branch: string) => {
if (onClone && selectedRepo) {
onClone(selectedRepo.http_url_to_repo, branch);
}
setSelectedRepo(null);
};
const handleCloseBranchSelector = () => {
setIsBranchSelectorOpen(false);
setSelectedRepo(null);
};
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, sortBy, filterBy]);
// Fetch repositories when connection is ready
useEffect(() => {
if (isConnected && connection?.token) {
fetchRepositories();
}
}, [isConnected, connection?.token]);
if (!isConnected || !connection) {
return (
<div className="text-center p-8">
<p className="text-bolt-elements-textSecondary mb-4">Please connect to GitLab first to browse repositories</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh Connection
</Button>
</div>
);
}
if (error && !repositories.length) {
return (
<div className="text-center p-8">
<div className="text-red-500 mb-4">
<GitBranch className="w-12 h-12 mx-auto mb-2" />
<p className="font-medium">Failed to load repositories</p>
<p className="text-sm text-bolt-elements-textSecondary mt-1">{error}</p>
</div>
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
Try Again
</Button>
</div>
);
}
if (isLoading && !repositories.length) {
return (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="animate-spin w-8 h-8 border-2 border-bolt-elements-borderColorActive border-t-transparent rounded-full" />
<p className="text-sm text-bolt-elements-textSecondary">Loading repositories...</p>
</div>
);
}
if (!repositories.length && !isLoading) {
return (
<div className="text-center p-8">
<GitBranch className="w-12 h-12 text-bolt-elements-textTertiary mx-auto mb-4" />
<p className="text-bolt-elements-textSecondary mb-4">No repositories found</p>
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
);
}
return (
<motion.div
className={classNames('space-y-6', className)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Header with stats */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Select Repository to Clone</h3>
<p className="text-sm text-bolt-elements-textSecondary">
{filteredRepositories.length} of {repositories.length} repositories
</p>
</div>
<Button
onClick={handleRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<RefreshCw className={classNames('w-4 h-4', { 'animate-spin': isRefreshing })} />
Refresh
</Button>
</div>
{error && repositories.length > 0 && (
<div className="p-3 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-700">
<p className="text-sm text-yellow-800 dark:text-yellow-200">Warning: {error}. Showing cached data.</p>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bolt-elements-textTertiary" />
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bolt-elements-background-depth-1 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>
{/* Sort */}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="updated">Recently updated</option>
<option value="stars">Most starred</option>
<option value="name">Name (A-Z)</option>
<option value="created">Recently created</option>
</select>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-bolt-elements-textTertiary" />
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
>
<option value="all">All repositories</option>
<option value="owned">Owned repositories</option>
<option value="member">Member repositories</option>
</select>
</div>
</div>
{/* Repository Grid */}
{currentRepositories.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentRepositories.map((repo) => (
<div key={repo.id} className="relative">
<RepositoryCard repo={repo} onClone={() => handleCloneRepository(repo)} />
</div>
))}
</div>
{/* Pagination */}
{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(startIndex + REPOS_PER_PAGE, 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"
>
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
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8">
<p className="text-bolt-elements-textSecondary">No repositories found matching your search criteria.</p>
</div>
)}
{/* Branch Selector Modal */}
{selectedRepo && (
<BranchSelector
provider="gitlab"
repoOwner={selectedRepo.path_with_namespace.split('/')[0]}
repoName={selectedRepo.path_with_namespace.split('/')[1]}
projectId={selectedRepo.id}
token={connection?.token || ''}
gitlabUrl={connection?.gitlabUrl}
defaultBranch={selectedRepo.default_branch}
onBranchSelect={handleBranchSelect}
onClose={handleCloseBranchSelector}
isOpen={isBranchSelectorOpen}
/>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryCardProps {
repo: GitLabProjectInfo;
onClone?: (repo: GitLabProjectInfo) => void;
}
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
return (
<a
key={repo.name}
href={repo.http_url_to_repo}
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: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>
</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.star_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>
)}
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
<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();
onClone(repo);
}}
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,142 @@
import React, { useState, useMemo } from 'react';
import { Button } from '~/components/ui/Button';
import { RepositoryCard } from './RepositoryCard';
import type { GitLabProjectInfo } from '~/types/GitLab';
interface RepositoryListProps {
repositories: GitLabProjectInfo[];
onClone?: (repo: GitLabProjectInfo) => 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.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
(repo.description && repo.description.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..."
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,91 @@
import React from 'react';
import { Button } from '~/components/ui/Button';
import type { GitLabStats } from '~/types/GitLab';
interface StatsDisplayProps {
stats: GitLabStats;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
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.publicProjects,
},
{
label: 'Private Repos',
value: stats.privateProjects,
},
].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}</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.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-bolt-elements-icon-warning',
},
{
label: 'Forks',
value: 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}
</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,4 @@
export { default as GitLabConnection } from './GitLabConnection';
export { RepositoryCard } from './RepositoryCard';
export { RepositoryList } from './RepositoryList';
export { StatsDisplay } from './StatsDisplay';