Final UI V3

# UI V3 Changelog

Major updates and improvements in this release:

## Core Changes
- Complete NEW REWRITTEN UI system overhaul (V3) with semantic design tokens
- New settings management system with drag-and-drop capabilities
- Enhanced provider system supporting multiple AI services
- Improved theme system with better dark mode support
- New component library with consistent design patterns

## Technical Updates
- Reorganized project architecture for better maintainability
- Performance optimizations and bundle size improvements
- Enhanced security features and access controls
- Improved developer experience with better tooling
- Comprehensive testing infrastructure

## New Features
- Background rays effect for improved visual feedback
- Advanced tab management system
- Automatic and manual update support
- Enhanced error handling and visualization
- Improved accessibility across all components

For detailed information about all changes and improvements, please see the full changelog.
This commit is contained in:
Stijnus
2025-02-02 01:42:30 +01:00
parent 999d87b1e8
commit fc3dd8c84c
76 changed files with 4540 additions and 4936 deletions

View File

@@ -0,0 +1,180 @@
import React, { useEffect } from 'react';
import { classNames } from '~/utils/classNames';
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
import Cookies from 'js-cookie';
import { getLocalStorage } from '~/lib/persistence';
const GITHUB_TOKEN_KEY = 'github_token';
interface ConnectionFormProps {
authState: GitHubAuthState;
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
onSave: (e: React.FormEvent) => void;
onDisconnect: () => void;
}
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount
useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: savedToken,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
}));
}
}, []);
return (
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
</div>
<div>
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
</div>
</div>
</div>
<form onSubmit={onSave} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
GitHub Username
</label>
<input
id="username"
type="text"
value={authState.username}
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="e.g., octocat"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
Personal Access Token
</label>
<a
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
target="_blank"
rel="noopener noreferrer"
className={classNames(
'inline-flex items-center gap-1.5 text-xs',
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
'transition-colors duration-200',
)}
>
<span>Generate new token</span>
<div className="i-ph:plus-circle" />
</a>
</div>
<input
id="token"
type="password"
value={authState.tokenInfo?.token || ''}
onChange={(e) =>
setAuthState((prev: GitHubAuthState) => ({
...prev,
tokenInfo: {
token: e.target.value,
scope: [],
avatar_url: '',
name: null,
created_at: new Date().toISOString(),
followers: 0,
},
username: '',
isConnected: false,
isVerifying: false,
isLoadingRepos: false,
}))
}
className={classNames(
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
'border-[#E5E5E5] dark:border-[#1A1A1A]',
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
'transition-all duration-200',
)}
placeholder="ghp_xxxxxxxxxxxx"
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-4">
{!authState.isConnected ? (
<button
type="submit"
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-purple-500 hover:bg-purple-600',
'text-white',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{authState.isVerifying ? (
<>
<div className="i-ph:spinner animate-spin" />
<span>Verifying...</span>
</>
) : (
<>
<div className="i-ph:plug-fill" />
<span>Connect</span>
</>
)}
</button>
) : (
<>
<button
onClick={onDisconnect}
className={classNames(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
'text-bolt-elements-textPrimary',
)}
>
<div className="i-ph:plug-fill" />
<span>Disconnect</span>
</button>
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
<div className="i-ph:check-circle-fill" />
<span>Connected</span>
</span>
</>
)}
</div>
{authState.rateLimits && (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
<div className="i-ph:clock-countdown opacity-60" />
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
</div>
)}
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
import { GitBranch } from '@phosphor-icons/react';
interface GitHubBranch {
name: string;
default?: boolean;
}
interface CreateBranchDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (branchName: string, sourceBranch: string) => void;
repository: GitHubRepoInfo;
branches?: GitHubBranch[];
}
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm(branchName, sourceBranch);
setBranchName('');
onClose();
};
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
<Dialog.Content
className={classNames(
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-full max-w-md p-6 rounded-xl shadow-lg',
'bg-white dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
Create New Branch
</Dialog.Title>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
Branch Name
</label>
<input
id="branchName"
type="text"
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
placeholder="feature/my-new-branch"
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
required
/>
</div>
<div>
<label
htmlFor="sourceBranch"
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
>
Source Branch
</label>
<select
id="sourceBranch"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
)}
>
{branches?.map((branch) => (
<option key={branch.name} value={branch.name}>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
</div>
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
<li className="flex items-center gap-2">
<GitBranch className="text-lg" />
Repository: {repository.name}
</li>
{branchName && (
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
New branch will be created as: {branchName}
</li>
)}
<li className="flex items-center gap-2">
<div className="i-ph:check-circle text-green-500" />
Based on: {sourceBranch}
</li>
</ul>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-bolt-elements-textPrimary',
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
'hover:bg-purple-500/10 hover:text-purple-500',
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
'transition-colors',
)}
>
Cancel
</button>
<button
type="submit"
className={classNames(
'px-4 py-2 rounded-lg text-sm font-medium',
'text-white bg-purple-500',
'hover:bg-purple-600',
'transition-colors',
)}
>
Create Branch
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,528 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { motion } from 'framer-motion';
import { getLocalStorage } from '~/lib/persistence';
import { classNames } from '~/utils/classNames';
import type { GitHubUserResponse } from '~/types/GitHub';
import { logStore } from '~/lib/stores/logs';
import { workbenchStore } from '~/lib/stores/workbench';
import { extractRelativePath } from '~/utils/diff';
import { formatSize } from '~/utils/formatSize';
import type { FileMap, File } from '~/lib/stores/files';
import { Octokit } from '@octokit/rest';
interface PushToGitHubDialogProps {
isOpen: boolean;
onClose: () => void;
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
private: boolean;
}
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
const [repoName, setRepoName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<GitHubUserResponse | null>(null);
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
// Load GitHub connection on mount
useEffect(() => {
if (isOpen) {
const connection = getLocalStorage('github_connection');
if (connection?.user && connection?.token) {
setUser(connection.user);
// Only fetch if we have both user and token
if (connection.token.trim()) {
fetchRecentRepos(connection.token);
}
}
}
}, [isOpen]);
const fetchRecentRepos = async (token: string) => {
if (!token) {
logStore.logError('No GitHub token available');
toast.error('GitHub authentication required');
return;
}
try {
setIsFetchingRepos(true);
const response = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token.trim()}`,
},
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
toast.error('GitHub token expired. Please reconnect your account.');
// Clear invalid token
const connection = getLocalStorage('github_connection');
if (connection) {
localStorage.removeItem('github_connection');
setUser(null);
}
} else {
logStore.logError('Failed to fetch GitHub repositories', {
status: response.status,
statusText: response.statusText,
error: errorData,
});
toast.error(`Failed to fetch repositories: ${response.statusText}`);
}
return;
}
const repos = (await response.json()) as GitHubRepo[];
setRecentRepos(repos);
} catch (error) {
logStore.logError('Failed to fetch GitHub repositories', { error });
toast.error('Failed to fetch recent repositories');
} finally {
setIsFetchingRepos(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const connection = getLocalStorage('github_connection');
if (!connection?.token || !connection?.user) {
toast.error('Please connect your GitHub account in Settings > Connections first');
return;
}
if (!repoName.trim()) {
toast.error('Repository name is required');
return;
}
setIsLoading(true);
try {
// Check if repository exists first
const octokit = new Octokit({ auth: connection.token });
try {
await octokit.repos.get({
owner: connection.user.login,
repo: repoName,
});
// If we get here, the repo exists
const confirmOverwrite = window.confirm(
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
);
if (!confirmOverwrite) {
setIsLoading(false);
return;
}
} catch (error) {
// 404 means repo doesn't exist, which is what we want for new repos
if (error instanceof Error && 'status' in error && error.status !== 404) {
throw error;
}
}
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
setCreatedRepoUrl(repoUrl);
// Get list of pushed files
const files = workbenchStore.files.get();
const filesList = Object.entries(files as FileMap)
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
.map(([path, dirent]) => ({
path: extractRelativePath(path),
size: new TextEncoder().encode((dirent as File).content || '').length,
}));
setPushedFiles(filesList);
setShowSuccessDialog(true);
} catch (error) {
console.error('Error pushing to GitHub:', error);
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setRepoName('');
setIsPrivate(false);
setShowSuccessDialog(false);
setCreatedRepoUrl('');
onClose();
};
// Success Dialog
if (showSuccessDialog) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<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-[600px] max-h-[85vh] overflow-y-auto"
>
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-green-500">
<div className="i-ph:check-circle w-5 h-5" />
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
</div>
<Dialog.Close
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
>
<div className="i-ph:x w-5 h-5" />
</Dialog.Close>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Repository URL
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
{createdRepoUrl}
</code>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph:copy w-4 h-4" />
</motion.button>
</div>
</div>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
Pushed Files ({pushedFiles.length})
</p>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
{pushedFiles.map((file) => (
<div
key={file.path}
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
<span className="font-mono truncate flex-1">{file.path}</span>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
{formatSize(file.size)}
</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<motion.a
href={createdRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:github-logo w-4 h-4" />
View Repository
</motion.a>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL copied to clipboard');
}}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:copy w-4 h-4" />
Copy URL
</motion.button>
<motion.button
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Close
</motion.button>
</div>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
if (!user) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<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-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:github-logo w-6 h-6" />
</motion.div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
</p>
<motion.button
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleClose}
>
<div className="i-ph:x-circle" />
Close
</motion.button>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<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-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
<div className="p-6">
<div className="flex items-center gap-4 mb-6">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:git-branch w-5 h-5" />
</motion.div>
<div>
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
Push to GitHub
</Dialog.Title>
<p className="text-sm text-gray-600 dark:text-gray-400">
Push your code to a new or existing GitHub repository
</p>
</div>
<Dialog.Close
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
onClick={handleClose}
>
<div className="i-ph:x w-5 h-5" />
</Dialog.Close>
</div>
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
Repository Name
</label>
<input
id="repoName"
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value)}
placeholder="my-awesome-project"
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
required
/>
</div>
{recentRepos.length > 0 && (
<div className="space-y-2">
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
<div className="space-y-2">
{recentRepos.map((repo) => (
<motion.button
key={repo.full_name}
type="button"
onClick={() => setRepoName(repo.name)}
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
{repo.name}
</span>
</div>
{repo.private && (
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
Private
</span>
)}
</div>
{repo.description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{repo.description}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{repo.language && (
<span className="flex items-center gap-1">
<div className="i-ph:code w-3 h-3" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1">
<div className="i-ph:star w-3 h-3" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork w-3 h-3" />
{repo.forks_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</motion.button>
))}
</div>
</div>
)}
{isFetchingRepos && (
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
Loading repositories...
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="private"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
/>
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
Make repository private
</label>
</div>
<div className="pt-4 flex gap-2">
<motion.button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Cancel
</motion.button>
<motion.button
type="submit"
disabled={isLoading}
className={classNames(
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
isLoading ? 'opacity-50 cursor-not-allowed' : '',
)}
whileHover={!isLoading ? { scale: 1.02 } : {}}
whileTap={!isLoading ? { scale: 0.98 } : {}}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
Pushing...
</>
) : (
<>
<div className="i-ph:git-branch w-4 h-4" />
Push to GitHub
</>
)}
</motion.button>
</div>
</form>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,693 @@
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import * as Dialog from '@radix-ui/react-dialog';
import { classNames } from '~/utils/classNames';
import { getLocalStorage } from '~/lib/persistence';
import { motion } from 'framer-motion';
import { formatSize } from '~/utils/formatSize';
import { Input } from '~/components/ui/Input';
interface GitHubTreeResponse {
tree: Array<{
path: string;
type: string;
size?: number;
}>;
}
interface RepositorySelectionDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (url: string) => void;
}
interface SearchFilters {
language?: string;
stars?: number;
forks?: number;
}
interface StatsDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
stats: RepositoryStats;
isLargeRepo?: boolean;
}
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<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-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
<div className="p-6 space-y-4">
<div>
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
<div className="mt-4 space-y-2">
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
<div className="flex items-center gap-2">
<span className="i-ph:files text-purple-500 w-4 h-4" />
<span>Total Files: {stats.totalFiles}</span>
</div>
<div className="flex items-center gap-2">
<span className="i-ph:database text-purple-500 w-4 h-4" />
<span>Total Size: {formatSize(stats.totalSize)}</span>
</div>
<div className="flex items-center gap-2">
<span className="i-ph:code text-purple-500 w-4 h-4" />
<span>
Languages:{' '}
{Object.entries(stats.languages)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
.join(', ')}
</span>
</div>
{stats.hasPackageJson && (
<div className="flex items-center gap-2">
<span className="i-ph:package text-purple-500 w-4 h-4" />
<span>Has package.json</span>
</div>
)}
{stats.hasDependencies && (
<div className="flex items-center gap-2">
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
<span>Has dependencies</span>
</div>
)}
</div>
</div>
{isLargeRepo && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
<div className="text-yellow-800 dark:text-yellow-500">
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
and could impact performance.
</div>
</div>
)}
</div>
</div>
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
>
OK
</button>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
const [customUrl, setCustomUrl] = useState('');
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
const [selectedBranch, setSelectedBranch] = useState('');
const [filters, setFilters] = useState<SearchFilters>({});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [stats, setStats] = useState<RepositoryStats | null>(null);
const [showStatsDialog, setShowStatsDialog] = useState(false);
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
// Fetch user's repositories when dialog opens
useEffect(() => {
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
}, [isOpen, activeTab]);
const fetchUserRepos = async () => {
const connection = getLocalStorage('github_connection');
if (!connection?.token) {
toast.error('Please connect your GitHub account first');
return;
}
setIsLoading(true);
try {
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch repositories');
}
const data = await response.json();
// Add type assertion and validation
if (
Array.isArray(data) &&
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
) {
setRepositories(data as GitHubRepoInfo[]);
} else {
throw new Error('Invalid repository data format');
}
} catch (error) {
console.error('Error fetching repos:', error);
toast.error('Failed to fetch your repositories');
} finally {
setIsLoading(false);
}
};
const handleSearch = async (query: string) => {
setIsLoading(true);
setSearchResults([]);
try {
let searchQuery = query;
if (filters.language) {
searchQuery += ` language:${filters.language}`;
}
if (filters.stars) {
searchQuery += ` stars:>${filters.stars}`;
}
if (filters.forks) {
searchQuery += ` forks:>${filters.forks}`;
}
const response = await fetch(
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
throw new Error('Failed to search repositories');
}
const data = await response.json();
// Add type assertion and validation
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
setSearchResults(data.items as GitHubRepoInfo[]);
} else {
throw new Error('Invalid search results format');
}
} catch (error) {
console.error('Error searching repos:', error);
toast.error('Failed to search repositories');
} finally {
setIsLoading(false);
}
};
const fetchBranches = async (repo: GitHubRepoInfo) => {
setIsLoading(true);
try {
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
headers: {
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch branches');
}
const data = await response.json();
// Add type assertion and validation
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
setBranches(
data.map((branch) => ({
name: branch.name,
default: branch.name === repo.default_branch,
})),
);
} else {
throw new Error('Invalid branch data format');
}
} catch (error) {
console.error('Error fetching branches:', error);
toast.error('Failed to fetch branches');
} finally {
setIsLoading(false);
}
};
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
setSelectedRepository(repo);
await fetchBranches(repo);
};
const formatGitUrl = (url: string): string => {
// Remove any tree references and ensure .git extension
const baseUrl = url
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
.replace(/\/$/, '') // Remove trailing slash
.replace(/\.git$/, ''); // Remove .git if present
return `${baseUrl}.git`;
};
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
try {
const [owner, repo] = repoUrl
.replace(/\.git$/, '')
.split('/')
.slice(-2);
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
// Fetch repository tree
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
headers,
});
if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure');
}
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
// Calculate repository stats
let totalSize = 0;
let totalFiles = 0;
const languages: { [key: string]: number } = {};
let hasPackageJson = false;
let hasDependencies = false;
for (const file of treeData.tree) {
if (file.type === 'blob') {
totalFiles++;
if (file.size) {
totalSize += file.size;
}
// Check for package.json
if (file.path === 'package.json') {
hasPackageJson = true;
// Fetch package.json content to check dependencies
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
headers,
});
if (contentResponse.ok) {
const content = (await contentResponse.json()) as GitHubContent;
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
hasDependencies = !!(
packageJson.dependencies ||
packageJson.devDependencies ||
packageJson.peerDependencies
);
}
}
// Detect language based on file extension
const ext = file.path.split('.').pop()?.toLowerCase();
if (ext) {
languages[ext] = (languages[ext] || 0) + (file.size || 0);
}
}
}
const stats: RepositoryStats = {
totalFiles,
totalSize,
languages,
hasPackageJson,
hasDependencies,
};
setStats(stats);
return stats;
} catch (error) {
console.error('Error verifying repository:', error);
toast.error('Failed to verify repository');
return null;
}
};
const handleImport = async () => {
try {
let gitUrl: string;
if (activeTab === 'url' && customUrl) {
gitUrl = formatGitUrl(customUrl);
} else if (selectedRepository) {
gitUrl = formatGitUrl(selectedRepository.html_url);
if (selectedBranch) {
gitUrl = `${gitUrl}#${selectedBranch}`;
}
} else {
return;
}
// Verify repository before importing
const stats = await verifyRepository(gitUrl);
if (!stats) {
return;
}
setCurrentStats(stats);
setPendingGitUrl(gitUrl);
setShowStatsDialog(true);
} catch (error) {
console.error('Error preparing repository:', error);
toast.error('Failed to prepare repository. Please try again.');
}
};
const handleStatsConfirm = () => {
setShowStatsDialog(false);
if (pendingGitUrl) {
onSelect(pendingGitUrl);
onClose();
}
};
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
let parsedValue: string | number | undefined = value;
if (key === 'stars' || key === 'forks') {
parsedValue = value ? parseInt(value, 10) : undefined;
}
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
handleSearch(searchQuery);
};
// Handle dialog close properly
const handleClose = () => {
setIsLoading(false); // Reset loading state
setSearchQuery(''); // Reset search
setSearchResults([]); // Reset results
onClose();
};
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Import GitHub Repository
</Dialog.Title>
<Dialog.Close
onClick={handleClose}
className={classNames(
'p-2 rounded-lg transition-all duration-200 ease-in-out',
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
)}
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</Dialog.Close>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
<span className="i-ph:book-bookmark" />
My Repos
</TabButton>
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
<span className="i-ph:magnifying-glass" />
Search
</TabButton>
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
<span className="i-ph:link" />
URL
</TabButton>
</div>
{activeTab === 'url' ? (
<div className="space-y-4">
<Input
placeholder="Enter repository URL"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={classNames('w-full', {
'border-red-500': false,
})}
/>
<button
onClick={handleImport}
disabled={!customUrl}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Repository
</button>
</div>
) : (
<>
{activeTab === 'search' && (
<div className="space-y-4 mb-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
handleSearch(e.target.value);
}}
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
/>
<button
onClick={() => setFilters({})}
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
<span className="i-ph:funnel-simple" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Filter by language..."
value={filters.language || ''}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value });
handleSearch(searchQuery);
}}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
<input
type="number"
placeholder="Min stars..."
value={filters.stars || ''}
onChange={(e) => handleFilterChange('stars', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
<input
type="number"
placeholder="Min forks..."
value={filters.forks || ''}
onChange={(e) => handleFilterChange('forks', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
)}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{selectedRepository ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedRepository(null)}
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
>
<span className="i-ph:arrow-left w-4 h-4" />
</button>
<h3 className="font-medium">{selectedRepository.full_name}</h3>
</div>
<div className="space-y-2">
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full px-3 py-2 rounded-lg 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 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
{branches.map((branch) => (
<option
key={branch.name}
value={branch.name}
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
<button
onClick={handleImport}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Selected Branch
</button>
</div>
</div>
) : (
<RepositoryList
repos={activeTab === 'my-repos' ? repositories : searchResults}
isLoading={isLoading}
onSelect={handleRepoSelect}
activeTab={activeTab}
/>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
{currentStats && (
<StatsDialog
isOpen={showStatsDialog}
onClose={handleStatsConfirm}
onConfirm={handleStatsConfirm}
stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/>
)}
</Dialog.Root>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={classNames(
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
active
? 'bg-purple-500 text-white hover:bg-purple-600'
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
)}
>
{children}
</button>
);
}
function RepositoryList({
repos,
isLoading,
onSelect,
activeTab,
}: {
repos: GitHubRepoInfo[];
isLoading: boolean;
onSelect: (repo: GitHubRepoInfo) => void;
activeTab: string;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
<span className="i-ph:spinner animate-spin mr-2" />
Loading repositories...
</div>
);
}
if (repos.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
</div>
);
}
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
}
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
return (
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
</div>
<button
onClick={onSelect}
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
>
<span className="i-ph:download-simple w-4 h-4" />
Import
</button>
</div>
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
{repo.language && (
<span className="flex items-center gap-1">
<span className="i-ph:code" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1">
<span className="i-ph:star" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<span className="i-ph:clock" />
{new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</div>
);
}