+ {Array.from({ length: lines }, (_, i) => (
+
1 ? 'w-3/4' : 'w-full',
+ )}
+ />
+ ))}
+
+ );
+}
+
+interface ServiceLoadingProps {
+ serviceName: string;
+ operation: string;
+ progress?: number;
+}
+
+export function ServiceLoading({ serviceName, operation, progress }: ServiceLoadingProps) {
+ return (
+
+ );
+}
diff --git a/app/components/@settings/shared/service-integration/ServiceHeader.tsx b/app/components/@settings/shared/service-integration/ServiceHeader.tsx
new file mode 100644
index 0000000..d2fec07
--- /dev/null
+++ b/app/components/@settings/shared/service-integration/ServiceHeader.tsx
@@ -0,0 +1,72 @@
+import React, { memo } from 'react';
+import { motion } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+
+interface ServiceHeaderProps {
+ icon: React.ComponentType<{ className?: string }>;
+ title: string;
+ description?: string;
+ onTestConnection?: () => void;
+ isTestingConnection?: boolean;
+ additionalInfo?: React.ReactNode;
+ delay?: number;
+}
+
+export const ServiceHeader = memo(
+ ({
+ icon: Icon, // eslint-disable-line @typescript-eslint/naming-convention
+ title,
+ description,
+ onTestConnection,
+ isTestingConnection,
+ additionalInfo,
+ delay = 0.1,
+ }: ServiceHeaderProps) => {
+ return (
+ <>
+
+
+
+
+ {title}
+
+
+
+ {additionalInfo}
+ {onTestConnection && (
+
+ )}
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+ >
+ );
+ },
+);
diff --git a/app/components/@settings/shared/service-integration/index.ts b/app/components/@settings/shared/service-integration/index.ts
new file mode 100644
index 0000000..a4186a9
--- /dev/null
+++ b/app/components/@settings/shared/service-integration/index.ts
@@ -0,0 +1,6 @@
+export { ConnectionTestIndicator } from './ConnectionTestIndicator';
+export type { ConnectionTestResult } from './ConnectionTestIndicator';
+export { ServiceHeader } from './ServiceHeader';
+export { ConnectionForm } from './ConnectionForm';
+export { LoadingState, Skeleton, ServiceLoading } from './LoadingState';
+export { ErrorState, ConnectionError } from './ErrorState';
diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx
deleted file mode 100644
index c1fae79..0000000
--- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { motion } from 'framer-motion';
-import React, { Suspense } from 'react';
-
-// Use React.lazy for dynamic imports
-const GitHubConnection = React.lazy(() => import('./github/GitHubConnection'));
-const GitlabConnection = React.lazy(() => import('./gitlab/GitLabConnection'));
-const NetlifyConnection = React.lazy(() => import('./netlify/NetlifyConnection'));
-const VercelConnection = React.lazy(() => import('./vercel/VercelConnection'));
-
-// Loading fallback component
-const LoadingFallback = () => (
-
-
-
-
Loading connection...
-
-
-);
-
-export default function ConnectionsTab() {
- return (
-
- {/* Header */}
-
-
-
- Connection Settings
-
-
-
- Manage your external service connections and integrations
-
-
-
- }>
-
-
- }>
-
-
- }>
-
-
- }>
-
-
-
-
- {/* Additional help text */}
-
-
-
- Troubleshooting Tip:
-
-
- If you're having trouble with connections, here are some troubleshooting tips to help resolve common issues.
-
-
For persistent issues:
-
- - Check your browser console for errors
- - Verify that your tokens have the correct permissions
- - Try clearing your browser cache and cookies
- - Ensure your browser allows third-party cookies if using integrations
-
-
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/AuthDialog.tsx b/app/components/@settings/tabs/connections/github/AuthDialog.tsx
deleted file mode 100644
index dd4b4e1..0000000
--- a/app/components/@settings/tabs/connections/github/AuthDialog.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import React, { useState } from 'react';
-import * as Dialog from '@radix-ui/react-dialog';
-import { motion } from 'framer-motion';
-import { toast } from 'react-toastify';
-import { Button } from '~/components/ui/Button';
-import { githubConnectionStore } from '~/lib/stores/githubConnection';
-
-interface AuthDialogProps {
- isOpen: boolean;
- onClose: () => void;
-}
-
-export function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
- const [token, setToken] = useState('');
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!token.trim()) {
- toast.error('Please enter a valid GitHub token');
- return;
- }
-
- setIsSubmitting(true);
-
- try {
- await githubConnectionStore.connect(token.trim(), tokenType);
- toast.success('Successfully connected to GitHub!');
- onClose();
- setToken('');
- } catch (error) {
- console.error('GitHub connection failed:', error);
- toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
- } finally {
- setIsSubmitting(false);
- }
- };
-
- const handleClose = () => {
- if (!isSubmitting) {
- setToken('');
- onClose();
- }
- };
-
- return (
-
-
-
-
-
-
-
- Connect to GitHub
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/GitHubConnection.tsx b/app/components/@settings/tabs/connections/github/GitHubConnection.tsx
deleted file mode 100644
index b762821..0000000
--- a/app/components/@settings/tabs/connections/github/GitHubConnection.tsx
+++ /dev/null
@@ -1,276 +0,0 @@
-import React, { useState } from 'react';
-import { motion } from 'framer-motion';
-import { toast } from 'react-toastify';
-import { useStore } from '@nanostores/react';
-import { classNames } from '~/utils/classNames';
-import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
-import { Button } from '~/components/ui/Button';
-import {
- githubConnectionAtom,
- githubConnectionStore,
- isGitHubConnected,
- isGitHubConnecting,
- isGitHubLoadingStats,
-} from '~/lib/stores/githubConnection';
-import { AuthDialog } from './AuthDialog';
-import { StatsDisplay } from './StatsDisplay';
-import { RepositoryList } from './RepositoryList';
-
-interface GitHubConnectionProps {
- onCloneRepository?: (repoUrl: string) => void;
-}
-
-export default function GitHubConnection({ onCloneRepository }: GitHubConnectionProps = {}) {
- const connection = useStore(githubConnectionAtom);
- const isConnected = useStore(isGitHubConnected);
- const isConnecting = useStore(isGitHubConnecting);
- const isLoadingStats = useStore(isGitHubLoadingStats);
-
- const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);
- const [isStatsExpanded, setIsStatsExpanded] = useState(false);
- const [isReposExpanded, setIsReposExpanded] = useState(false);
-
- const handleConnect = () => {
- setIsAuthDialogOpen(true);
- };
-
- const handleDisconnect = () => {
- githubConnectionStore.disconnect();
- setIsStatsExpanded(false);
- setIsReposExpanded(false);
- toast.success('Disconnected from GitHub');
- };
-
- const handleRefreshStats = async () => {
- try {
- await githubConnectionStore.fetchStats();
- toast.success('GitHub stats refreshed');
- } catch (error) {
- toast.error(`Failed to refresh stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- };
-
- const handleTokenTypeChange = (tokenType: 'classic' | 'fine-grained') => {
- githubConnectionStore.updateTokenType(tokenType);
- };
-
- const handleCloneRepository = (repoUrl: string) => {
- if (onCloneRepository) {
- onCloneRepository(repoUrl);
- } else {
- window.open(repoUrl, '_blank');
- }
- };
-
- return (
-
- {/* Header */}
-
-
-
-
-
GitHub
-
- {isConnected
- ? `Connected as ${connection.user?.login}`
- : 'Connect your GitHub account to manage repositories'}
-
-
-
-
-
- {isConnected ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
-
- {/* Connection Status */}
-
-
-
-
- {isConnected ? 'Connected' : 'Not Connected'}
-
-
- {connection.rateLimit && (
-
- Rate limit: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
-
- )}
-
-
- {/* Token Type Selection */}
- {isConnected && (
-
-
-
- {(['classic', 'fine-grained'] as const).map((type) => (
-
- ))}
-
-
- )}
-
-
- {/* User Profile */}
- {isConnected && connection.user && (
-
-
-

-
-
- {connection.user.name || connection.user.login}
-
-
@{connection.user.login}
- {connection.user.bio && (
-
{connection.user.bio}
- )}
-
-
-
- {connection.user.public_repos?.toLocaleString() || 0}
-
-
repositories
-
-
-
- )}
-
- {/* Stats Section */}
- {isConnected && connection.stats && (
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Repositories Section */}
- {isConnected && connection.stats?.repos && connection.stats.repos.length > 0 && (
-
-
-
-
-
-
- Repositories ({connection.stats.repos.length})
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Auth Dialog */}
-
setIsAuthDialogOpen(false)} />
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/RepositoryCard.tsx b/app/components/@settings/tabs/connections/github/RepositoryCard.tsx
deleted file mode 100644
index 780da30..0000000
--- a/app/components/@settings/tabs/connections/github/RepositoryCard.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react';
-import type { GitHubRepoInfo } from '~/types/GitHub';
-
-interface RepositoryCardProps {
- repo: GitHubRepoInfo;
- onClone?: (repoUrl: string) => void;
-}
-
-export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
- return (
-
-
-
-
-
-
- {repo.name}
-
- {repo.private && (
-
- Private
-
- )}
-
-
-
-
- {repo.stargazers_count.toLocaleString()}
-
-
-
- {repo.forks_count.toLocaleString()}
-
-
-
-
- {repo.description && (
-
{repo.description}
- )}
-
- {repo.topics && repo.topics.length > 0 && (
-
- {repo.topics.slice(0, 3).map((topic) => (
-
- {topic}
-
- ))}
- {repo.topics.length > 3 && (
-
- +{repo.topics.length - 3}
-
- )}
-
- )}
-
-
- {repo.language && (
-
-
- {repo.language}
-
- )}
-
-
- {repo.default_branch}
-
-
-
- {new Date(repo.updated_at).toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })}
-
-
- {onClone && (
-
- )}
-
-
- View
-
-
-
-
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/RepositoryList.tsx b/app/components/@settings/tabs/connections/github/RepositoryList.tsx
deleted file mode 100644
index ba9e6ae..0000000
--- a/app/components/@settings/tabs/connections/github/RepositoryList.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import React, { useState, useMemo } from 'react';
-import { Button } from '~/components/ui/Button';
-import { RepositoryCard } from './RepositoryCard';
-import type { GitHubRepoInfo } from '~/types/GitHub';
-
-interface RepositoryListProps {
- repositories: GitHubRepoInfo[];
- onClone?: (repoUrl: string) => void;
- onRefresh?: () => void;
- isRefreshing?: boolean;
-}
-
-const MAX_REPOS_PER_PAGE = 20;
-
-export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
- const [searchQuery, setSearchQuery] = useState('');
- const [currentPage, setCurrentPage] = useState(1);
- const [isSearching, setIsSearching] = useState(false);
-
- const filteredRepositories = useMemo(() => {
- if (!searchQuery) {
- return repositories;
- }
-
- setIsSearching(true);
-
- const filtered = repositories.filter(
- (repo) =>
- repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- (repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
- (repo.language && repo.language.toLowerCase().includes(searchQuery.toLowerCase())) ||
- (repo.topics && repo.topics.some((topic) => topic.toLowerCase().includes(searchQuery.toLowerCase()))),
- );
-
- setIsSearching(false);
-
- return filtered;
- }, [repositories, searchQuery]);
-
- const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
- const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
- const endIndex = startIndex + MAX_REPOS_PER_PAGE;
- const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
-
- const handleSearch = (query: string) => {
- setSearchQuery(query);
- setCurrentPage(1); // Reset to first page when searching
- };
-
- return (
-
-
-
- Repositories ({filteredRepositories.length})
-
- {onRefresh && (
-
- )}
-
-
- {/* Search Input */}
-
-
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"
- />
-
- {isSearching ? (
-
- ) : (
-
- )}
-
-
-
- {/* Repository Grid */}
-
- {filteredRepositories.length === 0 ? (
-
- {searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
-
- ) : (
- <>
-
- {currentRepositories.map((repo) => (
-
- ))}
-
-
- {/* Pagination Controls */}
- {totalPages > 1 && (
-
-
- Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
- {Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
-
-
-
-
- {currentPage} of {totalPages}
-
-
-
-
- )}
- >
- )}
-
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/StatsDisplay.tsx b/app/components/@settings/tabs/connections/github/StatsDisplay.tsx
deleted file mode 100644
index 9f2b926..0000000
--- a/app/components/@settings/tabs/connections/github/StatsDisplay.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import React from 'react';
-import { Button } from '~/components/ui/Button';
-import type { GitHubStats } from '~/types/GitHub';
-
-interface StatsDisplayProps {
- stats: GitHubStats;
- onRefresh?: () => void;
- isRefreshing?: boolean;
-}
-
-export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
- // Calculate top languages for display
- const topLanguages = Object.entries(stats.languages || {})
- .sort(([, a], [, b]) => b - a)
- .slice(0, 5);
-
- return (
-
- {/* Repository Stats */}
-
-
Repository Stats
-
- {[
- {
- label: 'Public Repos',
- value: stats.publicRepos || 0,
- },
- {
- label: 'Private Repos',
- value: stats.privateRepos || 0,
- },
- ].map((stat, index) => (
-
- {stat.label}
- {stat.value.toLocaleString()}
-
- ))}
-
-
-
- {/* Contribution Stats */}
-
-
Contribution Stats
-
- {[
- {
- label: 'Stars',
- value: stats.totalStars || stats.stars || 0,
- icon: 'i-ph:star',
- iconColor: 'text-bolt-elements-icon-warning',
- },
- {
- label: 'Forks',
- value: stats.totalForks || stats.forks || 0,
- icon: 'i-ph:git-fork',
- iconColor: 'text-bolt-elements-icon-info',
- },
- {
- label: 'Followers',
- value: stats.followers || 0,
- icon: 'i-ph:users',
- iconColor: 'text-bolt-elements-icon-success',
- },
- ].map((stat, index) => (
-
-
{stat.label}
-
-
- {stat.value.toLocaleString()}
-
-
- ))}
-
-
-
- {/* Gist Stats */}
-
-
Gist Stats
-
- {[
- {
- label: 'Public Gists',
- value: stats.publicGists || 0,
- icon: 'i-ph:note',
- },
- {
- label: 'Total Gists',
- value: stats.totalGists || 0,
- icon: 'i-ph:note-blank',
- },
- ].map((stat, index) => (
-
-
{stat.label}
-
-
- {stat.value.toLocaleString()}
-
-
- ))}
-
-
-
- {/* Top Languages */}
- {topLanguages.length > 0 && (
-
-
Top Languages
-
- {topLanguages.map(([language, count]) => (
-
- {language}
- {count} repositories
-
- ))}
-
-
- )}
-
- {/* Recent Activity */}
- {stats.recentActivity && stats.recentActivity.length > 0 && (
-
-
Recent Activity
-
- {stats.recentActivity.slice(0, 3).map((activity) => (
-
-
-
- {activity.type.replace('Event', '')} in {activity.repo.name}
-
-
- {new Date(activity.created_at).toLocaleDateString()}
-
-
- ))}
-
-
- )}
-
-
-
-
- Last updated: {new Date(stats.lastUpdated).toLocaleString()}
-
- {onRefresh && (
-
- )}
-
-
-
- );
-}
diff --git a/app/components/@settings/tabs/connections/github/index.ts b/app/components/@settings/tabs/connections/github/index.ts
deleted file mode 100644
index 5b906f8..0000000
--- a/app/components/@settings/tabs/connections/github/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { default as GitHubConnection } from './GitHubConnection';
-export { RepositoryCard } from './RepositoryCard';
-export { RepositoryList } from './RepositoryList';
-export { StatsDisplay } from './StatsDisplay';
-export { AuthDialog } from './AuthDialog';
diff --git a/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx b/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx
deleted file mode 100644
index 28b4112..0000000
--- a/app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx
+++ /dev/null
@@ -1,389 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { motion } from 'framer-motion';
-import { toast } from 'react-toastify';
-import { classNames } from '~/utils/classNames';
-import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
-import { Button } from '~/components/ui/Button';
-import { useGitLabConnection } from '~/lib/stores/gitlabConnection';
-import { RepositoryList } from './RepositoryList';
-import { StatsDisplay } from './StatsDisplay';
-import type { GitLabProjectInfo } from '~/types/GitLab';
-
-interface GitLabConnectionProps {
- onCloneRepository?: (repoUrl: string) => void;
-}
-
-export default function GitLabConnection({ onCloneRepository }: GitLabConnectionProps = {}) {
- const {
- connection: connectionAtom,
- isConnected,
- user: userAtom,
- stats,
- gitlabUrl: gitlabUrlAtom,
- connect,
- disconnect,
- fetchStats,
- loadSavedConnection,
- setGitLabUrl,
- setToken,
- autoConnect,
- } = useGitLabConnection();
-
- const [isLoading, setIsLoading] = useState(true);
- const [isConnecting, setIsConnecting] = useState(false);
- const [isFetchingStats, setIsFetchingStats] = useState(false);
- const [isStatsExpanded, setIsStatsExpanded] = useState(false);
-
- useEffect(() => {
- const initializeConnection = async () => {
- setIsLoading(true);
-
- const saved = loadSavedConnection();
-
- if (saved?.user && saved?.token) {
- // If we have stats, no need to fetch them again
- if (!saved.stats || !saved.stats.projects || saved.stats.projects.length === 0) {
- await fetchStats();
- }
- } else if (import.meta.env?.VITE_GITLAB_ACCESS_TOKEN) {
- // Auto-connect using environment variable if no saved connection
- const result = await autoConnect();
-
- if (result.success) {
- toast.success('Connected to GitLab automatically');
- }
- }
-
- setIsLoading(false);
- };
-
- initializeConnection();
- }, [autoConnect, fetchStats, loadSavedConnection]);
-
- const handleConnect = async (event: React.FormEvent) => {
- event.preventDefault();
- setIsConnecting(true);
-
- try {
- const result = await connect(connectionAtom.get().token, gitlabUrlAtom.get());
-
- if (result.success) {
- toast.success('Connected to GitLab successfully');
- await fetchStats();
- } else {
- toast.error(`Failed to connect to GitLab: ${result.error}`);
- }
- } catch (error) {
- console.error('Failed to connect to GitLab:', error);
- toast.error(`Failed to connect to GitLab: ${error instanceof Error ? error.message : 'Unknown error'}`);
- } finally {
- setIsConnecting(false);
- }
- };
-
- const handleDisconnect = () => {
- disconnect();
- toast.success('Disconnected from GitLab');
- };
-
- const handleCloneRepository = (repoUrl: string) => {
- if (onCloneRepository) {
- onCloneRepository(repoUrl);
- } else {
- window.open(repoUrl, '_blank');
- }
- };
-
- if (isLoading || isConnecting || isFetchingStats) {
- return (
-
- );
- }
-
- return (
-
-
-
-
- {!isConnected && (
-
-
-
- Tip: You can also set the{' '}
- VITE_GITLAB_ACCESS_TOKEN{' '}
- environment variable to connect automatically.
-
-
- For self-hosted GitLab instances, also set{' '}
-
- VITE_GITLAB_URL=https://your-gitlab-instance.com
-
-
-
- )}
-
-
-
-
- setGitLabUrl(e.target.value)}
- disabled={isConnecting || isConnected.get()}
- placeholder="https://gitlab.com"
- className={classNames(
- 'w-full px-3 py-2 rounded-lg text-sm',
- 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
- 'border border-[#E5E5E5] dark:border-[#333333]',
- 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
- 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
- 'disabled:opacity-50',
- )}
- />
-
-
-
-
-
setToken(e.target.value)}
- disabled={isConnecting || isConnected.get()}
- placeholder="Enter your GitLab access token"
- className={classNames(
- 'w-full px-3 py-2 rounded-lg text-sm',
- 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
- 'border border-[#E5E5E5] dark:border-[#333333]',
- 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
- 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
- 'disabled:opacity-50',
- )}
- />
-
-
-
-
-
- {!isConnected ? (
-
- ) : (
- <>
-
-
-
-
-
- Connected to GitLab
-
-
-
-
-
-
-
- >
- )}
-
-
- {isConnected.get() && userAtom.get() && stats.get() && (
-
-
-
- {userAtom.get()?.avatar_url &&
- userAtom.get()?.avatar_url !== 'null' &&
- userAtom.get()?.avatar_url !== '' ? (
-
?.avatar_url})
{
- // Fallback to initials if avatar fails to load
- const target = e.target as HTMLImageElement;
- target.style.display = 'none';
-
- const parent = target.parentElement;
-
- if (parent) {
- const user = userAtom.get();
- parent.innerHTML = (user?.name || user?.username || 'U').charAt(0).toUpperCase();
-
- parent.classList.add(
- 'text-white',
- 'font-semibold',
- 'text-sm',
- 'flex',
- 'items-center',
- 'justify-center',
- );
- }
- }}
- />
- ) : (
-
- {(userAtom.get()?.name || userAtom.get()?.username || 'U').charAt(0).toUpperCase()}
-
- )}
-
-
-
- {userAtom.get()?.name || userAtom.get()?.username}
-
-
{userAtom.get()?.username}
-
-
-
-
-
-
-
-
-
- {
- const result = await fetchStats();
-
- if (result.success) {
- toast.success('Stats refreshed');
- } else {
- toast.error(`Failed to refresh stats: ${result.error}`);
- }
- }}
- isRefreshing={isFetchingStats}
- />
-
- handleCloneRepository(repo.http_url_to_repo)}
- onRefresh={async () => {
- const result = await fetchStats(true); // Force refresh
-
- if (result.success) {
- toast.success('Repositories refreshed');
- } else {
- toast.error(`Failed to refresh repositories: ${result.error}`);
- }
- }}
- isRefreshing={isFetchingStats}
- />
-
-
-
-
- )}
-
-
- );
-}
diff --git a/app/components/@settings/tabs/github/GitHubTab.tsx b/app/components/@settings/tabs/github/GitHubTab.tsx
new file mode 100644
index 0000000..b619fb5
--- /dev/null
+++ b/app/components/@settings/tabs/github/GitHubTab.tsx
@@ -0,0 +1,281 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
+import { LoadingState, ErrorState, ConnectionTestIndicator, RepositoryCard } from './components/shared';
+import { GitHubConnection } from './components/GitHubConnection';
+import { GitHubUserProfile } from './components/GitHubUserProfile';
+import { GitHubStats } from './components/GitHubStats';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { classNames } from '~/utils/classNames';
+import { ChevronDown } from 'lucide-react';
+import { GitHubErrorBoundary } from './components/GitHubErrorBoundary';
+import { GitHubProgressiveLoader } from './components/GitHubProgressiveLoader';
+import { GitHubCacheManager } from './components/GitHubCacheManager';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+// GitHub logo SVG component
+const GithubLogo = () => (
+
+);
+
+export default function GitHubTab() {
+ const { connection, isConnected, isLoading, error, testConnection } = useGitHubConnection();
+ const {
+ stats,
+ isLoading: isStatsLoading,
+ error: statsError,
+ } = useGitHubStats(
+ connection,
+ {
+ autoFetch: true,
+ cacheTimeout: 30 * 60 * 1000, // 30 minutes
+ },
+ isConnected && connection ? !connection.token : false,
+ ); // Use server-side when no token but connected
+
+ const [connectionTest, setConnectionTest] = useState
(null);
+ const [isStatsExpanded, setIsStatsExpanded] = useState(false);
+ const [isReposExpanded, setIsReposExpanded] = 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.login}`,
+ 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 (
+
+
+
+
GitHub Integration
+
+
+
+ );
+ }
+
+ // Error state for connection issues
+ if (error && !connection) {
+ return (
+
+
+
+
GitHub Integration
+
+
window.location.reload()}
+ retryLabel="Reload Page"
+ />
+
+ );
+ }
+
+ // Not connected state
+ if (!isConnected || !connection) {
+ return (
+
+
+
+
GitHub Integration
+
+
+ Connect your GitHub account to enable advanced repository management features, statistics, and seamless
+ integration.
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ GitHub Integration
+
+
+
+ {connection?.rateLimit && (
+
+
+
+ API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
+
+
+ )}
+
+
+
+
+ Manage your GitHub integration with advanced repository features and comprehensive statistics
+
+
+ {/* Connection Test Results */}
+
+
+ {/* Connection Component */}
+
+
+ {/* User Profile */}
+ {connection.user &&
}
+
+ {/* Stats Section */}
+
+
+ {/* Repositories Section */}
+ {stats?.repos && stats.repos.length > 0 && (
+
+
+
+
+
+
+
+ All Repositories ({stats.repos.length})
+
+
+
+
+
+
+
+
+
+ {(isReposExpanded ? stats.repos : stats.repos.slice(0, 12)).map((repo) => (
+ window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
+ />
+ ))}
+
+
+ {stats.repos.length > 12 && !isReposExpanded && (
+
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* Stats Error State */}
+ {statsError && !stats && (
+
window.location.reload()}
+ retryLabel="Retry"
+ />
+ )}
+
+ {/* Stats Loading State */}
+ {isStatsLoading && !stats && (
+
+
+
+ )}
+
+ {/* Cache Management Section - Only show when connected */}
+ {isConnected && connection && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubAuthDialog.tsx b/app/components/@settings/tabs/github/components/GitHubAuthDialog.tsx
new file mode 100644
index 0000000..65a0486
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubAuthDialog.tsx
@@ -0,0 +1,173 @@
+import React, { useState } from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { motion } from 'framer-motion';
+import { classNames } from '~/utils/classNames';
+import { useGitHubConnection } from '~/lib/hooks';
+
+interface GitHubAuthDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess?: () => void;
+}
+
+export function GitHubAuthDialog({ isOpen, onClose, onSuccess }: GitHubAuthDialogProps) {
+ const { connect, isConnecting, error } = useGitHubConnection();
+ const [token, setToken] = useState('');
+ const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
+
+ const handleConnect = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!token.trim()) {
+ return;
+ }
+
+ try {
+ await connect(token, tokenType);
+ setToken(''); // Clear token on successful connection
+ onSuccess?.();
+ onClose();
+ } catch {
+ // Error handling is done in the hook
+ }
+ };
+
+ const handleClose = () => {
+ setToken('');
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+
+
+
Connect to GitHub
+
+
+
+
+
+
+ Tip: You need a GitHub token to deploy repositories.
+
+
Required scopes: repo, read:org, read:user
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx
new file mode 100644
index 0000000..0496929
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubCacheManager.tsx
@@ -0,0 +1,367 @@
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { Database, Trash2, RefreshCw, Clock, HardDrive, CheckCircle } from 'lucide-react';
+
+interface CacheEntry {
+ key: string;
+ size: number;
+ timestamp: number;
+ lastAccessed: number;
+ data: any;
+}
+
+interface CacheStats {
+ totalSize: number;
+ totalEntries: number;
+ oldestEntry: number;
+ newestEntry: number;
+ hitRate?: number;
+}
+
+interface GitHubCacheManagerProps {
+ className?: string;
+ showStats?: boolean;
+}
+
+// Cache management utilities
+class CacheManagerService {
+ private static readonly _cachePrefix = 'github_';
+ private static readonly _cacheKeys = [
+ 'github_connection',
+ 'github_stats_cache',
+ 'github_repositories_cache',
+ 'github_user_cache',
+ 'github_rate_limits',
+ ];
+
+ static getCacheEntries(): CacheEntry[] {
+ const entries: CacheEntry[] = [];
+
+ for (const key of this._cacheKeys) {
+ try {
+ const data = localStorage.getItem(key);
+
+ if (data) {
+ const parsed = JSON.parse(data);
+ entries.push({
+ key,
+ size: new Blob([data]).size,
+ timestamp: parsed.timestamp || Date.now(),
+ lastAccessed: parsed.lastAccessed || Date.now(),
+ data: parsed,
+ });
+ }
+ } catch (error) {
+ console.warn(`Failed to parse cache entry: ${key}`, error);
+ }
+ }
+
+ return entries.sort((a, b) => b.lastAccessed - a.lastAccessed);
+ }
+
+ static getCacheStats(): CacheStats {
+ const entries = this.getCacheEntries();
+
+ if (entries.length === 0) {
+ return {
+ totalSize: 0,
+ totalEntries: 0,
+ oldestEntry: 0,
+ newestEntry: 0,
+ };
+ }
+
+ const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
+ const timestamps = entries.map((e) => e.timestamp);
+
+ return {
+ totalSize,
+ totalEntries: entries.length,
+ oldestEntry: Math.min(...timestamps),
+ newestEntry: Math.max(...timestamps),
+ };
+ }
+
+ static clearCache(keys?: string[]): void {
+ const keysToRemove = keys || this._cacheKeys;
+
+ for (const key of keysToRemove) {
+ localStorage.removeItem(key);
+ }
+ }
+
+ static clearExpiredCache(maxAge: number = 24 * 60 * 60 * 1000): number {
+ const entries = this.getCacheEntries();
+ const now = Date.now();
+ let removedCount = 0;
+
+ for (const entry of entries) {
+ if (now - entry.timestamp > maxAge) {
+ localStorage.removeItem(entry.key);
+ removedCount++;
+ }
+ }
+
+ return removedCount;
+ }
+
+ static compactCache(): void {
+ const entries = this.getCacheEntries();
+
+ for (const entry of entries) {
+ try {
+ // Re-serialize with minimal data
+ const compacted = {
+ ...entry.data,
+ lastAccessed: Date.now(),
+ };
+ localStorage.setItem(entry.key, JSON.stringify(compacted));
+ } catch (error) {
+ console.warn(`Failed to compact cache entry: ${entry.key}`, error);
+ }
+ }
+ }
+
+ static formatSize(bytes: number): string {
+ if (bytes === 0) {
+ return '0 B';
+ }
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ }
+}
+
+export function GitHubCacheManager({ className = '', showStats = true }: GitHubCacheManagerProps) {
+ const [cacheEntries, setCacheEntries] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [lastClearTime, setLastClearTime] = useState(null);
+
+ const refreshCacheData = useCallback(() => {
+ setCacheEntries(CacheManagerService.getCacheEntries());
+ }, []);
+
+ useEffect(() => {
+ refreshCacheData();
+ }, [refreshCacheData]);
+
+ const cacheStats = useMemo(() => CacheManagerService.getCacheStats(), [cacheEntries]);
+
+ const handleClearAll = useCallback(async () => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.clearCache();
+ setLastClearTime(Date.now());
+ refreshCacheData();
+
+ // Trigger a page refresh to update all components
+ setTimeout(() => {
+ window.location.reload();
+ }, 1000);
+ } catch (error) {
+ console.error('Failed to clear cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleClearExpired = useCallback(() => {
+ setIsLoading(true);
+
+ try {
+ const removedCount = CacheManagerService.clearExpiredCache();
+ refreshCacheData();
+
+ if (removedCount > 0) {
+ // Show success message or trigger update
+ console.log(`Removed ${removedCount} expired cache entries`);
+ }
+ } catch (error) {
+ console.error('Failed to clear expired cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleCompactCache = useCallback(() => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.compactCache();
+ refreshCacheData();
+ } catch (error) {
+ console.error('Failed to compact cache:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [refreshCacheData]);
+
+ const handleClearSpecific = useCallback(
+ (key: string) => {
+ setIsLoading(true);
+
+ try {
+ CacheManagerService.clearCache([key]);
+ refreshCacheData();
+ } catch (error) {
+ console.error(`Failed to clear cache key: ${key}`, error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [refreshCacheData],
+ );
+
+ if (!showStats && cacheEntries.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
GitHub Cache Management
+
+
+
+
+
+
+
+ {showStats && (
+
+
+
+
+ Total Size
+
+
+ {CacheManagerService.formatSize(cacheStats.totalSize)}
+
+
+
+
+
+
+ Entries
+
+
{cacheStats.totalEntries}
+
+
+
+
+
+ Oldest
+
+
+ {cacheStats.oldestEntry ? new Date(cacheStats.oldestEntry).toLocaleDateString() : 'N/A'}
+
+
+
+
+
+
+ Status
+
+
+ {cacheStats.totalEntries > 0 ? 'Active' : 'Empty'}
+
+
+
+ )}
+
+ {cacheEntries.length > 0 && (
+
+
+ Cache Entries ({cacheEntries.length})
+
+
+
+ {cacheEntries.map((entry) => (
+
+
+
+ {entry.key.replace('github_', '')}
+
+
+ {CacheManagerService.formatSize(entry.size)} • {new Date(entry.lastAccessed).toLocaleString()}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ {cacheEntries.length > 0 && (
+
+ )}
+
+
+ {lastClearTime && (
+
+
+ Cache cleared successfully at {new Date(lastClearTime).toLocaleTimeString()}
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubConnection.tsx b/app/components/@settings/tabs/github/components/GitHubConnection.tsx
new file mode 100644
index 0000000..f7f5d66
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubConnection.tsx
@@ -0,0 +1,233 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { useGitHubConnection } from '~/lib/hooks';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface GitHubConnectionProps {
+ connectionTest: ConnectionTestResult | null;
+ onTestConnection: () => void;
+}
+
+export function GitHubConnection({ connectionTest, onTestConnection }: GitHubConnectionProps) {
+ const { isConnected, isLoading, isConnecting, connect, disconnect, error } = useGitHubConnection();
+
+ const [token, setToken] = React.useState('');
+ const [tokenType, setTokenType] = React.useState<'classic' | 'fine-grained'>('classic');
+
+ const handleConnect = async (e: React.FormEvent) => {
+ e.preventDefault();
+ console.log('handleConnect called with token:', token ? 'token provided' : 'no token', 'tokenType:', tokenType);
+
+ if (!token.trim()) {
+ console.log('No token provided, returning early');
+ return;
+ }
+
+ try {
+ console.log('Calling connect function...');
+ await connect(token, tokenType);
+ console.log('Connect function completed successfully');
+ setToken(''); // Clear token on successful connection
+ } catch (error) {
+ console.log('Connect function failed:', error);
+
+ // Error handling is done in the hook
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading connection...
+
+
+ );
+ }
+
+ return (
+
+
+ {!isConnected && (
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_GITHUB_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+ For fine-grained tokens, also set{' '}
+
+ VITE_GITHUB_TOKEN_TYPE=fine-grained
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx
new file mode 100644
index 0000000..531f682
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx
@@ -0,0 +1,105 @@
+import React, { Component } from 'react';
+import type { ReactNode, ErrorInfo } from 'react';
+import { Button } from '~/components/ui/Button';
+import { AlertTriangle } from 'lucide-react';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class GitHubErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('GitHub Error Boundary caught an error:', error, errorInfo);
+
+ if (this.props.onError) {
+ this.props.onError(error, errorInfo);
+ }
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ return (
+
+
+
+
+
GitHub Integration Error
+
+ Something went wrong while loading GitHub data. This could be due to network issues, API limits, or a
+ temporary problem.
+
+
+ {this.state.error && (
+
+ Show error details
+
+ {this.state.error.message}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+// Higher-order component for wrapping components with error boundary
+export function withGitHubErrorBoundary(component: React.ComponentType
) {
+ return function WrappedComponent(props: P) {
+ return {React.createElement(component, props)};
+ };
+}
+
+// Hook for handling async errors in GitHub operations
+export function useGitHubErrorHandler() {
+ const handleError = React.useCallback((error: unknown, context?: string) => {
+ console.error(`GitHub Error ${context ? `(${context})` : ''}:`, error);
+
+ /*
+ * You could integrate with error tracking services here
+ * For example: Sentry, LogRocket, etc.
+ */
+
+ return error instanceof Error ? error.message : 'An unknown error occurred';
+ }, []);
+
+ return { handleError };
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx
new file mode 100644
index 0000000..7f28ee1
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx
@@ -0,0 +1,266 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Button } from '~/components/ui/Button';
+import { classNames } from '~/utils/classNames';
+import { Loader2, ChevronDown, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
+
+interface ProgressiveLoaderProps {
+ isLoading: boolean;
+ isRefreshing?: boolean;
+ error?: string | null;
+ onRetry?: () => void;
+ onRefresh?: () => void;
+ children: React.ReactNode;
+ className?: string;
+ loadingMessage?: string;
+ refreshingMessage?: string;
+ showProgress?: boolean;
+ progressSteps?: Array<{
+ key: string;
+ label: string;
+ completed: boolean;
+ loading?: boolean;
+ error?: boolean;
+ }>;
+}
+
+export function GitHubProgressiveLoader({
+ isLoading,
+ isRefreshing = false,
+ error,
+ onRetry,
+ onRefresh,
+ children,
+ className = '',
+ loadingMessage = 'Loading...',
+ refreshingMessage = 'Refreshing...',
+ showProgress = false,
+ progressSteps = [],
+}: ProgressiveLoaderProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Calculate progress percentage
+ const progress = useMemo(() => {
+ if (!showProgress || progressSteps.length === 0) {
+ return 0;
+ }
+
+ const completed = progressSteps.filter((step) => step.completed).length;
+
+ return Math.round((completed / progressSteps.length) * 100);
+ }, [showProgress, progressSteps]);
+
+ const handleToggleExpanded = useCallback(() => {
+ setIsExpanded((prev) => !prev);
+ }, []);
+
+ // Loading state with progressive steps
+ if (isLoading) {
+ return (
+
+
+
+ {showProgress && progress > 0 && (
+
+ {progress}%
+
+ )}
+
+
+
+
{loadingMessage}
+
+ {showProgress && progressSteps.length > 0 && (
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Steps toggle */}
+
+
+ {/* Progress steps */}
+
+ {isExpanded && (
+
+ {progressSteps.map((step) => (
+
+ {step.error ? (
+
+ ) : step.completed ? (
+
+ ) : step.loading ? (
+
+ ) : (
+
+ )}
+
+ {step.label}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
+
+
Failed to Load
+
{error}
+
+
+
+ {onRetry && (
+
+ )}
+ {onRefresh && (
+
+ )}
+
+
+ );
+ }
+
+ // Success state - render children with optional refresh indicator
+ return (
+
+ {isRefreshing && (
+
+
+
+ {refreshingMessage}
+
+
+ )}
+
+ {children}
+
+ );
+}
+
+// Hook for managing progressive loading steps
+export function useProgressiveLoader() {
+ const [steps, setSteps] = useState<
+ Array<{
+ key: string;
+ label: string;
+ completed: boolean;
+ loading?: boolean;
+ error?: boolean;
+ }>
+ >([]);
+
+ const addStep = useCallback((key: string, label: string) => {
+ setSteps((prev) => [
+ ...prev.filter((step) => step.key !== key),
+ { key, label, completed: false, loading: false, error: false },
+ ]);
+ }, []);
+
+ const updateStep = useCallback(
+ (
+ key: string,
+ updates: {
+ completed?: boolean;
+ loading?: boolean;
+ error?: boolean;
+ label?: string;
+ },
+ ) => {
+ setSteps((prev) => prev.map((step) => (step.key === key ? { ...step, ...updates } : step)));
+ },
+ [],
+ );
+
+ const removeStep = useCallback((key: string) => {
+ setSteps((prev) => prev.filter((step) => step.key !== key));
+ }, []);
+
+ const clearSteps = useCallback(() => {
+ setSteps([]);
+ }, []);
+
+ const startStep = useCallback(
+ (key: string) => {
+ updateStep(key, { loading: true, error: false });
+ },
+ [updateStep],
+ );
+
+ const completeStep = useCallback(
+ (key: string) => {
+ updateStep(key, { completed: true, loading: false, error: false });
+ },
+ [updateStep],
+ );
+
+ const errorStep = useCallback(
+ (key: string) => {
+ updateStep(key, { error: true, loading: false });
+ },
+ [updateStep],
+ );
+
+ return {
+ steps,
+ addStep,
+ updateStep,
+ removeStep,
+ clearSteps,
+ startStep,
+ completeStep,
+ errorStep,
+ };
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubRepositoryCard.tsx b/app/components/@settings/tabs/github/components/GitHubRepositoryCard.tsx
new file mode 100644
index 0000000..2f70906
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubRepositoryCard.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+
+interface GitHubRepositoryCardProps {
+ repo: GitHubRepoInfo;
+ onClone?: (repo: GitHubRepoInfo) => void;
+}
+
+export function GitHubRepositoryCard({ repo, onClone }: GitHubRepositoryCardProps) {
+ return (
+
+
+
+
+
+
+
+ {repo.name}
+
+ {repo.private && (
+
+ )}
+ {repo.fork && (
+
+ )}
+ {repo.archived && (
+
+ )}
+
+
+
+
+ {repo.stargazers_count.toLocaleString()}
+
+
+
+ {repo.forks_count.toLocaleString()}
+
+
+
+
+ {repo.description && (
+
{repo.description}
+ )}
+
+
+
+
+ {repo.default_branch}
+
+ {repo.language && (
+
+
+ {repo.language}
+
+ )}
+
+
+ {new Date(repo.updated_at).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ {/* Repository topics/tags */}
+ {repo.topics && repo.topics.length > 0 && (
+
+ {repo.topics.slice(0, 3).map((topic) => (
+
+ {topic}
+
+ ))}
+ {repo.topics.length > 3 && (
+ +{repo.topics.length - 3} more
+ )}
+
+ )}
+
+ {/* Repository size if available */}
+ {repo.size && (
+
Size: {(repo.size / 1024).toFixed(1)} MB
+ )}
+
+
+ {/* Bottom section with Clone button positioned at bottom right */}
+
+
+
+ View
+
+ {onClone && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubRepositorySelector.tsx b/app/components/@settings/tabs/github/components/GitHubRepositorySelector.tsx
new file mode 100644
index 0000000..6fb0bed
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubRepositorySelector.tsx
@@ -0,0 +1,312 @@
+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 { GitHubRepositoryCard } from './GitHubRepositoryCard';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+import { useGitHubConnection, useGitHubStats } from '~/lib/hooks';
+import { classNames } from '~/utils/classNames';
+import { Search, RefreshCw, GitBranch, Calendar, Filter } from 'lucide-react';
+
+interface GitHubRepositorySelectorProps {
+ onClone?: (repoUrl: string, branch?: string) => void;
+ className?: string;
+}
+
+type SortOption = 'updated' | 'stars' | 'name' | 'created';
+type FilterOption = 'all' | 'own' | 'forks' | 'archived';
+
+export function GitHubRepositorySelector({ onClone, className }: GitHubRepositorySelectorProps) {
+ const { connection, isConnected } = useGitHubConnection();
+ const {
+ stats,
+ isLoading: isStatsLoading,
+ refreshStats,
+ } = useGitHubStats(connection, {
+ autoFetch: true,
+ cacheTimeout: 30 * 60 * 1000, // 30 minutes
+ });
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortBy, setSortBy] = useState('updated');
+ const [filterBy, setFilterBy] = useState('all');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [selectedRepo, setSelectedRepo] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
+ const [error, setError] = useState(null);
+
+ const repositories = stats?.repos || [];
+ const REPOS_PER_PAGE = 12;
+
+ // Filter and search repositories
+ const filteredRepositories = useMemo(() => {
+ if (!repositories) {
+ return [];
+ }
+
+ const filtered = repositories.filter((repo: GitHubRepoInfo) => {
+ // Search filter
+ const matchesSearch =
+ !searchQuery ||
+ repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ repo.full_name.toLowerCase().includes(searchQuery.toLowerCase());
+
+ // Type filter
+ let matchesFilter = true;
+
+ switch (filterBy) {
+ case 'own':
+ matchesFilter = !repo.fork;
+ break;
+ case 'forks':
+ matchesFilter = repo.fork === true;
+ break;
+ case 'archived':
+ matchesFilter = repo.archived === true;
+ break;
+ case 'all':
+ default:
+ matchesFilter = true;
+ break;
+ }
+
+ return matchesSearch && matchesFilter;
+ });
+
+ // Sort repositories
+ filtered.sort((a: GitHubRepoInfo, b: GitHubRepoInfo) => {
+ switch (sortBy) {
+ case 'name':
+ return a.name.localeCompare(b.name);
+ case 'stars':
+ return b.stargazers_count - a.stargazers_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 = async () => {
+ setIsRefreshing(true);
+ setError(null);
+
+ try {
+ await refreshStats();
+ } catch (err) {
+ console.error('Failed to refresh GitHub repositories:', err);
+ setError(err instanceof Error ? err.message : 'Failed to refresh repositories');
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const handleCloneRepository = (repo: GitHubRepoInfo) => {
+ setSelectedRepo(repo);
+ setIsBranchSelectorOpen(true);
+ };
+
+ const handleBranchSelect = (branch: string) => {
+ if (onClone && selectedRepo) {
+ const cloneUrl = selectedRepo.html_url + '.git';
+ onClone(cloneUrl, branch);
+ }
+
+ setSelectedRepo(null);
+ };
+
+ const handleCloseBranchSelector = () => {
+ setIsBranchSelectorOpen(false);
+ setSelectedRepo(null);
+ };
+
+ // Reset to first page when filters change
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchQuery, sortBy, filterBy]);
+
+ if (!isConnected || !connection) {
+ return (
+
+
Please connect to GitHub first to browse repositories
+
+
+ );
+ }
+
+ if (isStatsLoading && !stats) {
+ return (
+
+
+
Loading repositories...
+
+ );
+ }
+
+ if (!repositories.length) {
+ return (
+
+
+
No repositories found
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with stats */}
+
+
+
Select Repository to Clone
+
+ {filteredRepositories.length} of {repositories.length} repositories
+
+
+
+
+
+ {error && repositories.length > 0 && (
+
+
Warning: {error}. Showing cached data.
+
+ )}
+
+ {/* Search and Filters */}
+
+ {/* Search */}
+
+
+ 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"
+ />
+
+
+ {/* Sort */}
+
+
+
+
+
+ {/* Filter */}
+
+
+
+
+
+
+ {/* Repository Grid */}
+ {currentRepositories.length > 0 ? (
+ <>
+
+ {currentRepositories.map((repo) => (
+ handleCloneRepository(repo)} />
+ ))}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
+ {Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
+ repositories
+
+
+
+
+ {currentPage} of {totalPages}
+
+
+
+
+ )}
+ >
+ ) : (
+
+
No repositories found matching your search criteria.
+
+ )}
+
+ {/* Branch Selector Modal */}
+ {selectedRepo && (
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubStats.tsx b/app/components/@settings/tabs/github/components/GitHubStats.tsx
new file mode 100644
index 0000000..4b7d8fb
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubStats.tsx
@@ -0,0 +1,291 @@
+import React from 'react';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { classNames } from '~/utils/classNames';
+import { useGitHubStats } from '~/lib/hooks';
+import type { GitHubConnection, GitHubStats as GitHubStatsType } from '~/types/GitHub';
+import { GitHubErrorBoundary } from './GitHubErrorBoundary';
+
+interface GitHubStatsProps {
+ connection: GitHubConnection;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean) => void;
+}
+
+export function GitHubStats({ connection, isExpanded, onToggleExpanded }: GitHubStatsProps) {
+ const { stats, isLoading, isRefreshing, refreshStats, isStale } = useGitHubStats(
+ connection,
+ {
+ autoFetch: true,
+ cacheTimeout: 30 * 60 * 1000, // 30 minutes
+ },
+ !connection?.token,
+ ); // Use server-side if no token
+
+ return (
+
+
+
+ );
+}
+
+function GitHubStatsContent({
+ stats,
+ isLoading,
+ isRefreshing,
+ refreshStats,
+ isStale,
+ isExpanded,
+ onToggleExpanded,
+}: {
+ stats: GitHubStatsType | null;
+ isLoading: boolean;
+ isRefreshing: boolean;
+ refreshStats: () => Promise;
+ isStale: boolean;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean) => void;
+}) {
+ if (!stats) {
+ return (
+
+
+
+ {isLoading ? (
+ <>
+
+
Loading GitHub stats...
+ >
+ ) : (
+
No stats available
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ GitHub Stats
+ {isStale && (Stale)}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Languages Section */}
+
+
Top Languages
+ {stats.mostUsedLanguages && stats.mostUsedLanguages.length > 0 ? (
+
+
+ {stats.mostUsedLanguages.slice(0, 15).map(({ language, bytes, repos }) => (
+
+ {language} ({repos})
+
+ ))}
+
+
+ Based on actual codebase size across repositories
+
+
+ ) : (
+
+ {Object.entries(stats.languages)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 5)
+ .map(([language]) => (
+
+ {language}
+
+ ))}
+
+ )}
+
+
+ {/* GitHub Overview Summary */}
+
+
GitHub Overview
+
+
+
+ {(stats.publicRepos || 0) + (stats.privateRepos || 0)}
+
+
Total Repositories
+
+
+
{stats.totalBranches || 0}
+
Total Branches
+
+
+
+ {stats.organizations?.length || 0}
+
+
Organizations
+
+
+
+ {Object.keys(stats.languages).length}
+
+
Languages Used
+
+
+
+
+ {/* Activity Summary */}
+
+
Activity Summary
+
+ {[
+ {
+ label: 'Total Branches',
+ value: stats.totalBranches || 0,
+ icon: 'i-ph:git-branch',
+ iconColor: 'text-bolt-elements-icon-info',
+ },
+ {
+ label: 'Contributors',
+ value: stats.totalContributors || 0,
+ icon: 'i-ph:users',
+ iconColor: 'text-bolt-elements-icon-success',
+ },
+ {
+ label: 'Issues',
+ value: stats.totalIssues || 0,
+ icon: 'i-ph:circle',
+ iconColor: 'text-bolt-elements-icon-warning',
+ },
+ {
+ label: 'Pull Requests',
+ value: stats.totalPullRequests || 0,
+ icon: 'i-ph:git-pull-request',
+ iconColor: 'text-bolt-elements-icon-accent',
+ },
+ ].map((stat, index) => (
+
+
{stat.label}
+
+
+ {stat.value.toLocaleString()}
+
+
+ ))}
+
+
+
+ {/* Organizations Section */}
+ {stats.organizations && stats.organizations.length > 0 && (
+
+ )}
+
+ {/* Last Updated */}
+
+
+ Last updated: {stats.lastUpdated ? new Date(stats.lastUpdated).toLocaleString() : 'Never'}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx
new file mode 100644
index 0000000..fd56860
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/GitHubUserProfile.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import type { GitHubUserResponse } from '~/types/GitHub';
+
+interface GitHubUserProfileProps {
+ user: GitHubUserResponse;
+ className?: string;
+}
+
+export function GitHubUserProfile({ user, className = '' }: GitHubUserProfileProps) {
+ return (
+
+

+
+
+ {user.name || user.login}
+
+
@{user.login}
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+
+
+ {user.followers} followers
+
+
+
+ {user.public_repos} public repos
+
+
+
+ {user.public_gists} gists
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx
new file mode 100644
index 0000000..c36fa09
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx
@@ -0,0 +1,264 @@
+import React from 'react';
+import { Loader2, AlertCircle, CheckCircle, Info, Github } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+
+interface LoadingStateProps {
+ message?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function LoadingState({ message = 'Loading...', size = 'md', className = '' }: LoadingStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+ );
+}
+
+interface ErrorStateProps {
+ title?: string;
+ message: string;
+ onRetry?: () => void;
+ retryLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function ErrorState({
+ title = 'Error',
+ message,
+ onRetry,
+ retryLabel = 'Try Again',
+ size = 'md',
+ className = '',
+}: ErrorStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+
+
{title}
+
{message}
+ {onRetry && (
+
+ )}
+
+ );
+}
+
+interface SuccessStateProps {
+ title?: string;
+ message: string;
+ onAction?: () => void;
+ actionLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function SuccessState({
+ title = 'Success',
+ message,
+ onAction,
+ actionLabel = 'Continue',
+ size = 'md',
+ className = '',
+}: SuccessStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+
+
{title}
+
{message}
+ {onAction && (
+
+ )}
+
+ );
+}
+
+interface GitHubConnectionRequiredProps {
+ onConnect?: () => void;
+ className?: string;
+}
+
+export function GitHubConnectionRequired({ onConnect, className = '' }: GitHubConnectionRequiredProps) {
+ return (
+
+
+
GitHub Connection Required
+
+ Please connect your GitHub account to access this feature. You'll be able to browse repositories, push code, and
+ manage your GitHub integration.
+
+ {onConnect && (
+
+ )}
+
+ );
+}
+
+interface InformationStateProps {
+ title: string;
+ message: string;
+ icon?: React.ComponentType<{ className?: string }>;
+ onAction?: () => void;
+ actionLabel?: string;
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+}
+
+export function InformationState({
+ title,
+ message,
+ icon = Info,
+ onAction,
+ actionLabel = 'Got it',
+ size = 'md',
+ className = '',
+}: InformationStateProps) {
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ };
+
+ const textSizeClasses = {
+ sm: 'text-sm',
+ md: 'text-base',
+ lg: 'text-lg',
+ };
+
+ return (
+
+ {React.createElement(icon, { className: classNames('text-blue-500 mb-2', sizeClasses[size]) })}
+
{title}
+
{message}
+ {onAction && (
+
+ )}
+
+ );
+}
+
+interface ConnectionTestIndicatorProps {
+ status: 'success' | 'error' | 'testing' | null;
+ message?: string;
+ timestamp?: number;
+ className?: string;
+}
+
+export function ConnectionTestIndicator({ status, message, timestamp, className = '' }: ConnectionTestIndicatorProps) {
+ if (!status) {
+ return null;
+ }
+
+ const getStatusColor = () => {
+ switch (status) {
+ case 'success':
+ return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700';
+ case 'error':
+ return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700';
+ case 'testing':
+ return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700';
+ default:
+ return 'bg-gray-50 border-gray-200 dark:bg-gray-900/20 dark:border-gray-700';
+ }
+ };
+
+ const getStatusIcon = () => {
+ switch (status) {
+ case 'success':
+ return ;
+ case 'error':
+ return ;
+ case 'testing':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusTextColor = () => {
+ switch (status) {
+ case 'success':
+ return 'text-green-800 dark:text-green-200';
+ case 'error':
+ return 'text-red-800 dark:text-red-200';
+ case 'testing':
+ return 'text-blue-800 dark:text-blue-200';
+ default:
+ return 'text-gray-800 dark:text-gray-200';
+ }
+ };
+
+ return (
+
+
+ {getStatusIcon()}
+ {message || status}
+
+ {timestamp &&
{new Date(timestamp).toLocaleString()}
}
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx
new file mode 100644
index 0000000..f0ff7fa
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx
@@ -0,0 +1,361 @@
+import React from 'react';
+import { classNames } from '~/utils/classNames';
+import { formatSize } from '~/utils/formatSize';
+import type { GitHubRepoInfo } from '~/types/GitHub';
+import {
+ Star,
+ GitFork,
+ Clock,
+ Lock,
+ Archive,
+ GitBranch,
+ Users,
+ Database,
+ Tag,
+ Heart,
+ ExternalLink,
+ Circle,
+ GitPullRequest,
+} from 'lucide-react';
+
+interface RepositoryCardProps {
+ repository: GitHubRepoInfo;
+ variant?: 'default' | 'compact' | 'detailed';
+ onSelect?: () => void;
+ showHealthScore?: boolean;
+ showExtendedMetrics?: boolean;
+ className?: string;
+}
+
+export function RepositoryCard({
+ repository,
+ variant = 'default',
+ onSelect,
+ showHealthScore = false,
+ showExtendedMetrics = false,
+ className = '',
+}: RepositoryCardProps) {
+ const daysSinceUpdate = Math.floor((Date.now() - new Date(repository.updated_at).getTime()) / (1000 * 60 * 60 * 24));
+
+ const formatTimeAgo = () => {
+ if (daysSinceUpdate === 0) {
+ return 'Today';
+ }
+
+ if (daysSinceUpdate === 1) {
+ return '1 day ago';
+ }
+
+ if (daysSinceUpdate < 7) {
+ return `${daysSinceUpdate} days ago`;
+ }
+
+ if (daysSinceUpdate < 30) {
+ return `${Math.floor(daysSinceUpdate / 7)} weeks ago`;
+ }
+
+ return new Date(repository.updated_at).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ const calculateHealthScore = () => {
+ const hasStars = repository.stargazers_count > 0;
+ const hasRecentActivity = daysSinceUpdate < 30;
+ const hasContributors = (repository.contributors_count || 0) > 1;
+ const hasDescription = !!repository.description;
+ const hasTopics = (repository.topics || []).length > 0;
+ const hasLicense = !!repository.license;
+
+ const healthScore = [hasStars, hasRecentActivity, hasContributors, hasDescription, hasTopics, hasLicense].filter(
+ Boolean,
+ ).length;
+
+ const maxScore = 6;
+ const percentage = Math.round((healthScore / maxScore) * 100);
+
+ const getScoreColor = (score: number) => {
+ if (score >= 5) {
+ return 'text-green-500';
+ }
+
+ if (score >= 3) {
+ return 'text-yellow-500';
+ }
+
+ return 'text-red-500';
+ };
+
+ return {
+ percentage,
+ color: getScoreColor(healthScore),
+ score: healthScore,
+ maxScore,
+ };
+ };
+
+ const getHealthIndicatorColor = () => {
+ const isActive = daysSinceUpdate < 7;
+ const isHealthy = daysSinceUpdate < 30 && !repository.archived && repository.stargazers_count > 0;
+
+ if (repository.archived) {
+ return 'bg-gray-500';
+ }
+
+ if (isActive) {
+ return 'bg-green-500';
+ }
+
+ if (isHealthy) {
+ return 'bg-blue-500';
+ }
+
+ return 'bg-yellow-500';
+ };
+
+ const getHealthTitle = () => {
+ if (repository.archived) {
+ return 'Archived';
+ }
+
+ if (daysSinceUpdate < 7) {
+ return 'Very Active';
+ }
+
+ if (daysSinceUpdate < 30 && repository.stargazers_count > 0) {
+ return 'Healthy';
+ }
+
+ return 'Needs Attention';
+ };
+
+ const health = showHealthScore ? calculateHealthScore() : null;
+
+ if (variant === 'compact') {
+ return (
+
+ );
+ }
+
+ const Component = onSelect ? 'button' : 'div';
+ const interactiveProps = onSelect
+ ? {
+ onClick: onSelect,
+ className: classNames(
+ 'group cursor-pointer hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200',
+ className,
+ ),
+ }
+ : { className };
+
+ return (
+
+ {/* Repository Health Indicator */}
+ {variant === 'detailed' && (
+
+ )}
+
+
+
+
+
+
+ {repository.name}
+
+ {repository.fork && (
+
+
+
+ )}
+ {repository.archived && (
+
+
+
+ )}
+
+
+
+
+ {repository.stargazers_count.toLocaleString()}
+
+
+
+ {repository.forks_count.toLocaleString()}
+
+ {showExtendedMetrics && repository.issues_count !== undefined && (
+
+
+ {repository.issues_count}
+
+ )}
+ {showExtendedMetrics && repository.pull_requests_count !== undefined && (
+
+
+ {repository.pull_requests_count}
+
+ )}
+
+
+
+
+ {repository.description && (
+
{repository.description}
+ )}
+
+ {/* Repository metrics bar */}
+
+ {repository.license && (
+
+ {repository.license.spdx_id || repository.license.name}
+
+ )}
+ {repository.topics &&
+ repository.topics.slice(0, 2).map((topic) => (
+
+ {topic}
+
+ ))}
+ {repository.archived && (
+
+ Archived
+
+ )}
+ {repository.fork && (
+
+ Fork
+
+ )}
+
+
+
+
+
+
+
+ {repository.default_branch}
+
+ {showExtendedMetrics && repository.branches_count && (
+
+
+ {repository.branches_count}
+
+ )}
+ {showExtendedMetrics && repository.contributors_count && (
+
+
+ {repository.contributors_count}
+
+ )}
+ {repository.size && (
+
+
+ {(repository.size / 1024).toFixed(1)}MB
+
+ )}
+
+
+ {formatTimeAgo()}
+
+ {repository.topics && repository.topics.length > 0 && (
+
+
+ {repository.topics.length}
+
+ )}
+
+
+
+ {/* Repository Health Score */}
+ {health && (
+
+
+ {health.percentage}%
+
+ )}
+
+ {onSelect && (
+
+
+ View
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/github/components/shared/index.ts b/app/components/@settings/tabs/github/components/shared/index.ts
new file mode 100644
index 0000000..1564436
--- /dev/null
+++ b/app/components/@settings/tabs/github/components/shared/index.ts
@@ -0,0 +1,11 @@
+export { RepositoryCard } from './RepositoryCard';
+
+// GitHubDialog components not yet implemented
+export {
+ LoadingState,
+ ErrorState,
+ SuccessState,
+ GitHubConnectionRequired,
+ InformationState,
+ ConnectionTestIndicator,
+} from './GitHubStateIndicators';
diff --git a/app/components/@settings/tabs/gitlab/GitLabTab.tsx b/app/components/@settings/tabs/gitlab/GitLabTab.tsx
new file mode 100644
index 0000000..a2e4212
--- /dev/null
+++ b/app/components/@settings/tabs/gitlab/GitLabTab.tsx
@@ -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 = () => (
+
+);
+
+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(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 (
+
+
+
+
GitLab Integration
+
+
+
+ );
+ }
+
+ // Error state for connection issues
+ if (error && !connection) {
+ return (
+
+
+
+
GitLab Integration
+
+
+ {error}
+
+
+ );
+ }
+
+ // Not connected state
+ if (!isConnected || !connection) {
+ return (
+
+
+
+
GitLab Integration
+
+
+ Connect your GitLab account to enable advanced repository management features, statistics, and seamless
+ integration.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ GitLab Integration
+
+
+
+ {connection?.rateLimit && (
+
+
+
+ API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
+
+
+ )}
+
+
+
+
+ Manage your GitLab integration with advanced repository features and comprehensive statistics
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+
+ {connectionTest.status === 'success' ? (
+
+ ) : connectionTest.status === 'error' ? (
+
+ ) : (
+
+ )}
+
+
+ {connectionTest.message}
+
+
+
+ )}
+
+ {/* GitLab Connection Component */}
+
+
+ {/* User Profile Section */}
+ {connection?.user && (
+
+
+
+ {connection.user.avatar_url &&
+ connection.user.avatar_url !== 'null' &&
+ connection.user.avatar_url !== '' ? (
+

{
+ 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',
+ );
+ }
+ }}
+ />
+ ) : (
+
+ {(connection.user?.name || connection.user?.username || 'U').charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {connection.user?.name || connection.user?.username}
+
+
{connection.user?.username}
+
+
+
+ )}
+
+ {/* GitLab Stats Section */}
+ {connection?.stats && (
+
+ Statistics
+ {
+ setIsRefreshingStats(true);
+
+ try {
+ await refreshStats();
+ } catch (error) {
+ console.error('Failed to refresh stats:', error);
+ } finally {
+ setIsRefreshingStats(false);
+ }
+ }}
+ isRefreshing={isRefreshingStats}
+ />
+
+ )}
+
+ {/* GitLab Repositories Section */}
+ {connection?.stats?.projects && (
+
+ {
+ setIsRefreshingStats(true);
+
+ try {
+ await refreshStats();
+ } catch (error) {
+ console.error('Failed to refresh repositories:', error);
+ } finally {
+ setIsRefreshingStats(false);
+ }
+ }}
+ isRefreshing={isRefreshingStats}
+ />
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/gitlab/components/GitLabAuthDialog.tsx b/app/components/@settings/tabs/gitlab/components/GitLabAuthDialog.tsx
new file mode 100644
index 0000000..da6b5be
--- /dev/null
+++ b/app/components/@settings/tabs/gitlab/components/GitLabAuthDialog.tsx
@@ -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 (
+ !open && onClose()}>
+
+
+
+
+
+
+ Connect to GitLab
+
+
+
+
+
+
+ GitLab Connection
+
+
+ Connect your GitLab account to deploy your projects
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/gitlab/components/GitLabConnection.tsx b/app/components/@settings/tabs/gitlab/components/GitLabConnection.tsx
new file mode 100644
index 0000000..efdb6bd
--- /dev/null
+++ b/app/components/@settings/tabs/gitlab/components/GitLabConnection.tsx
@@ -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 (
+
+
+
+
+ {!isConnected && (
+
+
+
+ Tip: You can also set the{' '}
+ VITE_GITLAB_ACCESS_TOKEN{' '}
+ environment variable to connect automatically.
+
+
+ For self-hosted GitLab instances, also set{' '}
+
+ VITE_GITLAB_URL=https://your-gitlab-instance.com
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/gitlab/components/GitLabRepositorySelector.tsx b/app/components/@settings/tabs/gitlab/components/GitLabRepositorySelector.tsx
new file mode 100644
index 0000000..3f56bb1
--- /dev/null
+++ b/app/components/@settings/tabs/gitlab/components/GitLabRepositorySelector.tsx
@@ -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([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortBy, setSortBy] = useState('updated');
+ const [filterBy, setFilterBy] = useState('all');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [error, setError] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [selectedRepo, setSelectedRepo] = useState(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 (
+
+
Please connect to GitLab first to browse repositories
+
+
+ );
+ }
+
+ if (error && !repositories.length) {
+ return (
+
+
+
+
Failed to load repositories
+
{error}
+
+
+
+ );
+ }
+
+ if (isLoading && !repositories.length) {
+ return (
+
+
+
Loading repositories...
+
+ );
+ }
+
+ if (!repositories.length && !isLoading) {
+ return (
+
+
+
No repositories found
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with stats */}
+
+
+
Select Repository to Clone
+
+ {filteredRepositories.length} of {repositories.length} repositories
+
+
+
+
+
+ {error && repositories.length > 0 && (
+
+
Warning: {error}. Showing cached data.
+
+ )}
+
+ {/* Search and Filters */}
+
+ {/* Search */}
+
+
+ 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"
+ />
+
+
+ {/* Sort */}
+
+
+
+
+
+ {/* Filter */}
+
+
+
+
+
+
+ {/* Repository Grid */}
+ {currentRepositories.length > 0 ? (
+ <>
+
+ {currentRepositories.map((repo) => (
+
+ handleCloneRepository(repo)} />
+
+ ))}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
+ {Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
+ repositories
+
+
+
+
+ {currentPage} of {totalPages}
+
+
+
+
+ )}
+ >
+ ) : (
+
+
No repositories found matching your search criteria.
+
+ )}
+
+ {/* Branch Selector Modal */}
+ {selectedRepo && (
+
+ )}
+
+ );
+}
diff --git a/app/components/@settings/tabs/connections/gitlab/RepositoryCard.tsx b/app/components/@settings/tabs/gitlab/components/RepositoryCard.tsx
similarity index 100%
rename from app/components/@settings/tabs/connections/gitlab/RepositoryCard.tsx
rename to app/components/@settings/tabs/gitlab/components/RepositoryCard.tsx
diff --git a/app/components/@settings/tabs/connections/gitlab/RepositoryList.tsx b/app/components/@settings/tabs/gitlab/components/RepositoryList.tsx
similarity index 100%
rename from app/components/@settings/tabs/connections/gitlab/RepositoryList.tsx
rename to app/components/@settings/tabs/gitlab/components/RepositoryList.tsx
diff --git a/app/components/@settings/tabs/connections/gitlab/StatsDisplay.tsx b/app/components/@settings/tabs/gitlab/components/StatsDisplay.tsx
similarity index 100%
rename from app/components/@settings/tabs/connections/gitlab/StatsDisplay.tsx
rename to app/components/@settings/tabs/gitlab/components/StatsDisplay.tsx
diff --git a/app/components/@settings/tabs/connections/gitlab/index.ts b/app/components/@settings/tabs/gitlab/components/index.ts
similarity index 100%
rename from app/components/@settings/tabs/connections/gitlab/index.ts
rename to app/components/@settings/tabs/gitlab/components/index.ts
diff --git a/app/components/@settings/tabs/netlify/NetlifyTab.tsx b/app/components/@settings/tabs/netlify/NetlifyTab.tsx
new file mode 100644
index 0000000..7f41dab
--- /dev/null
+++ b/app/components/@settings/tabs/netlify/NetlifyTab.tsx
@@ -0,0 +1,1393 @@
+import React, { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { classNames } from '~/utils/classNames';
+import { useStore } from '@nanostores/react';
+import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
+import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import { formatDistanceToNow } from 'date-fns';
+import { Badge } from '~/components/ui/Badge';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface SiteAction {
+ name: string;
+ icon: string;
+ action: (siteId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Netlify logo SVG component
+const NetlifyLogo = () => (
+
+);
+
+export default function NetlifyTab() {
+ const connection = useStore(netlifyConnection);
+ const [tokenInput, setTokenInput] = useState('');
+ const [fetchingStats, setFetchingStats] = useState(false);
+ const [sites, setSites] = useState([]);
+ const [deploys, setDeploys] = useState([]);
+ const [deploymentCount, setDeploymentCount] = useState(0);
+ const [lastUpdated, setLastUpdated] = useState('');
+ const [isStatsOpen, setIsStatsOpen] = useState(false);
+ const [activeSiteIndex, setActiveSiteIndex] = useState(0);
+ const [isSitesExpanded, setIsSitesExpanded] = useState(false);
+ const [isDeploysExpanded, setIsDeploysExpanded] = useState(false);
+ const [isActionLoading, setIsActionLoading] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [connectionTest, setConnectionTest] = useState(null);
+
+ // Connection testing function
+ const testConnection = async () => {
+ if (!connection.token) {
+ setConnectionTest({
+ status: 'error',
+ message: 'No token provided',
+ timestamp: Date.now(),
+ });
+ return;
+ }
+
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const response = await fetch('https://api.netlify.com/api/v1/user', {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as any;
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully as ${data.email}`,
+ timestamp: Date.now(),
+ });
+ } else {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${response.status} ${response.statusText}`,
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Site actions
+ const siteActions: SiteAction[] = [
+ {
+ name: 'Clear Cache',
+ icon: 'i-ph:arrows-clockwise',
+ action: async (siteId: string) => {
+ try {
+ setIsActionLoading(true);
+
+ // Try to get site details first to check for build hooks
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ const errorText = await siteResponse.text();
+
+ if (siteResponse.status === 404) {
+ toast.error('Site not found. This may be a free account limitation.');
+ return;
+ }
+
+ throw new Error(`Failed to get site details: ${errorText}`);
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+
+ // Check if this looks like a free account (limited features)
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // If site has build hooks, try triggering a build instead
+ if (siteData.build_settings && siteData.build_settings.repo_url) {
+ // Try to trigger a build by making a POST to the site's build endpoint
+ const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ clear_cache: true,
+ }),
+ });
+
+ if (buildResponse.ok) {
+ toast.success('Build triggered with cache clear');
+ return;
+ } else if (buildResponse.status === 422) {
+ // Often indicates free account limitation
+ toast.warning('Build trigger failed. This feature may not be available on free accounts.');
+ return;
+ }
+ }
+
+ // Fallback: Try the standard cache purge endpoint
+ const cacheResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/purge_cache`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!cacheResponse.ok) {
+ if (cacheResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.warning('Cache purge not available on free accounts. Try triggering a build instead.');
+ } else {
+ toast.error('Cache purge endpoint not found. This feature may not be available.');
+ }
+
+ return;
+ }
+
+ const errorText = await cacheResponse.text();
+ throw new Error(`Cache purge failed: ${errorText}`);
+ }
+
+ toast.success('Site cache cleared successfully');
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to clear site cache: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ },
+ },
+ {
+ name: 'Manage Environment',
+ icon: 'i-ph:gear',
+ action: async (siteId: string) => {
+ try {
+ setIsActionLoading(true);
+
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // Get environment variables
+ const envResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/env`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (envResponse.ok) {
+ const envVars = (await envResponse.json()) as any[];
+ toast.success(`Environment variables loaded: ${envVars.length} variables`);
+ } else if (envResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Environment variables management is limited on free accounts');
+ } else {
+ toast.info('Site has no environment variables configured');
+ }
+ } else {
+ const errorText = await envResponse.text();
+ toast.error(`Failed to load environment variables: ${errorText}`);
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load environment variables: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ },
+ },
+ {
+ name: 'Trigger Build',
+ icon: 'i-ph:rocket-launch',
+ action: async (siteId: string) => {
+ try {
+ setIsActionLoading(true);
+
+ const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!buildResponse.ok) {
+ throw new Error('Failed to trigger build');
+ }
+
+ const buildData = (await buildResponse.json()) as any;
+ toast.success(`Build triggered successfully! ID: ${buildData.id}`);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to trigger build: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: 'i-ph:code',
+ action: async (siteId: string) => {
+ try {
+ setIsActionLoading(true);
+
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ const functionsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/functions`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (functionsResponse.ok) {
+ const functions = (await functionsResponse.json()) as any[];
+ toast.success(`Site has ${functions.length} serverless functions`);
+ } else if (functionsResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Functions may be limited or unavailable on free accounts');
+ } else {
+ toast.info('Site has no serverless functions');
+ }
+ } else {
+ const errorText = await functionsResponse.text();
+ toast.error(`Failed to load functions: ${errorText}`);
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load functions: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ },
+ },
+ {
+ name: 'Site Analytics',
+ icon: 'i-ph:chart-bar',
+ action: async (siteId: string) => {
+ try {
+ setIsActionLoading(true);
+
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // Get site traffic data (if available)
+ const analyticsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/traffic`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (analyticsResponse.ok) {
+ await analyticsResponse.json(); // Analytics data received
+ toast.success('Site analytics loaded successfully');
+ } else if (analyticsResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Analytics not available on free accounts. Showing basic site info instead.');
+ }
+
+ // Fallback to basic site info
+ toast.info(`Site: ${siteData.name} - Status: ${siteData.state || 'Unknown'}`);
+ } else {
+ const errorText = await analyticsResponse.text();
+
+ if (isFreeAccount) {
+ toast.info(
+ 'Analytics unavailable on free accounts. Site info: ' +
+ `${siteData.name} (${siteData.state || 'Unknown'})`,
+ );
+ } else {
+ toast.error(`Failed to load analytics: ${errorText}`);
+ }
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load site analytics: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ },
+ },
+ {
+ name: 'Delete Site',
+ icon: 'i-ph:trash',
+ action: async (siteId: string) => {
+ try {
+ const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete site');
+ }
+
+ toast.success('Site deleted successfully');
+ fetchNetlifyStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to delete site: ${error}`);
+ }
+ },
+ requiresConfirmation: true,
+ variant: 'destructive',
+ },
+ ];
+
+ // Deploy management functions
+ const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
+ try {
+ setIsActionLoading(true);
+
+ const endpoint =
+ action === 'publish'
+ ? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
+ : `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to ${action} deploy`);
+ }
+
+ toast.success(`Deploy ${action}ed successfully`);
+ fetchNetlifyStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to ${action} deploy: ${error}`);
+ } finally {
+ setIsActionLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ // Initialize connection with environment token if available
+ initializeNetlifyConnection();
+ }, []);
+
+ useEffect(() => {
+ // Check if we have a connection with a token but no stats
+ if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
+ fetchNetlifyStats(connection.token);
+ }
+
+ // Update local state from connection
+ if (connection.stats) {
+ setSites(connection.stats.sites || []);
+ setDeploys(connection.stats.deploys || []);
+ setDeploymentCount(connection.stats.deploys?.length || 0);
+ setLastUpdated(connection.stats.lastDeployTime || '');
+ }
+ }, [connection]);
+
+ const handleConnect = async () => {
+ if (!tokenInput) {
+ toast.error('Please enter a Netlify API token');
+ return;
+ }
+
+ setIsConnecting(true);
+
+ try {
+ const response = await fetch('https://api.netlify.com/api/v1/user', {
+ headers: {
+ Authorization: `Bearer ${tokenInput}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const userData = (await response.json()) as NetlifyUser;
+
+ // Update the connection store
+ updateNetlifyConnection({
+ user: userData,
+ token: tokenInput,
+ });
+
+ toast.success('Connected to Netlify successfully');
+
+ // Fetch stats after successful connection
+ fetchNetlifyStats(tokenInput);
+ } catch (error) {
+ console.error('Error connecting to Netlify:', error);
+ toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setIsConnecting(false);
+ setTokenInput('');
+ }
+ };
+
+ const handleDisconnect = () => {
+ // Clear from localStorage
+ localStorage.removeItem('netlify_connection');
+
+ // Remove cookies
+ document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+
+ // Update the store
+ updateNetlifyConnection({ user: null, token: '' });
+ setConnectionTest(null);
+ toast.success('Disconnected from Netlify');
+ };
+
+ const fetchNetlifyStats = async (token: string) => {
+ setFetchingStats(true);
+
+ try {
+ // Fetch sites
+ const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!sitesResponse.ok) {
+ throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
+ }
+
+ const sitesData = (await sitesResponse.json()) as NetlifySite[];
+ setSites(sitesData);
+
+ // Fetch deploys and builds for ALL sites
+ const allDeploysData: NetlifyDeploy[] = [];
+ const allBuildsData: NetlifyBuild[] = [];
+ let lastDeployTime = '';
+ let totalDeploymentCount = 0;
+
+ if (sitesData && sitesData.length > 0) {
+ // Process sites in batches to avoid overwhelming the API
+ const batchSize = 3;
+ const siteBatches = [];
+
+ for (let i = 0; i < sitesData.length; i += batchSize) {
+ siteBatches.push(sitesData.slice(i, i + batchSize));
+ }
+
+ for (const batch of siteBatches) {
+ const batchPromises = batch.map(async (site) => {
+ try {
+ // Fetch deploys for this site
+ const deploysResponse = await fetch(
+ `https://api.netlify.com/api/v1/sites/${site.id}/deploys?per_page=20`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+
+ let siteDeploys: NetlifyDeploy[] = [];
+
+ if (deploysResponse.ok) {
+ siteDeploys = (await deploysResponse.json()) as NetlifyDeploy[];
+ }
+
+ // Fetch builds for this site
+ const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${site.id}/builds?per_page=10`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ let siteBuilds: NetlifyBuild[] = [];
+
+ if (buildsResponse.ok) {
+ siteBuilds = (await buildsResponse.json()) as NetlifyBuild[];
+ }
+
+ return { site, deploys: siteDeploys, builds: siteBuilds };
+ } catch (error) {
+ console.error(`Failed to fetch data for site ${site.name}:`, error);
+ return { site, deploys: [], builds: [] };
+ }
+ });
+
+ const batchResults = await Promise.all(batchPromises);
+
+ for (const result of batchResults) {
+ allDeploysData.push(...result.deploys);
+ allBuildsData.push(...result.builds);
+ totalDeploymentCount += result.deploys.length;
+ }
+
+ // Small delay between batches
+ if (batch !== siteBatches[siteBatches.length - 1]) {
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ }
+
+ // Sort deploys by creation date (newest first)
+ allDeploysData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+
+ // Set the most recent deploy time
+ if (allDeploysData.length > 0) {
+ lastDeployTime = allDeploysData[0].created_at;
+ setLastUpdated(lastDeployTime);
+ }
+
+ setDeploys(allDeploysData);
+ setDeploymentCount(totalDeploymentCount);
+ }
+
+ // Update the stats in the store
+ updateNetlifyConnection({
+ stats: {
+ sites: sitesData,
+ deploys: allDeploysData,
+ builds: allBuildsData,
+ lastDeployTime,
+ totalSites: sitesData.length,
+ totalDeploys: totalDeploymentCount,
+ totalBuilds: allBuildsData.length,
+ },
+ });
+
+ toast.success('Netlify stats updated');
+ } catch (error) {
+ console.error('Error fetching Netlify stats:', error);
+ toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setFetchingStats(false);
+ }
+ };
+
+ const renderStats = () => {
+ if (!connection.user || !connection.stats) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* Netlify Overview Dashboard */}
+
+
Netlify Overview
+
+
+
+ {connection.stats.totalSites}
+
+
Total Sites
+
+
+
+ {connection.stats.totalDeploys || deploymentCount}
+
+
Total Deployments
+
+
+
+ {connection.stats.totalBuilds || 0}
+
+
Total Builds
+
+
+
+ {sites.filter((site) => site.published_deploy?.state === 'ready').length}
+
+
Live Sites
+
+
+
+
+ {/* Advanced Analytics */}
+
+
Deployment Analytics
+
+
+
+
+ Success Rate
+
+
+ {(() => {
+ const successfulDeploys = deploys.filter((deploy) => deploy.state === 'ready').length;
+ const failedDeploys = deploys.filter((deploy) => deploy.state === 'error').length;
+ const successRate =
+ deploys.length > 0 ? Math.round((successfulDeploys / deploys.length) * 100) : 0;
+
+ return [
+ { label: 'Success Rate', value: `${successRate}%` },
+ { label: 'Successful', value: successfulDeploys },
+ { label: 'Failed', value: failedDeploys },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Recent Activity
+
+
+ {(() => {
+ const now = Date.now();
+ const last24Hours = deploys.filter(
+ (deploy) => now - new Date(deploy.created_at).getTime() < 24 * 60 * 60 * 1000,
+ ).length;
+ const last7Days = deploys.filter(
+ (deploy) => now - new Date(deploy.created_at).getTime() < 7 * 24 * 60 * 60 * 1000,
+ ).length;
+ const activeSites = sites.filter((site) => {
+ const lastDeploy = site.published_deploy?.published_at;
+ return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000;
+ }).length;
+
+ return [
+ { label: 'Last 24 hours', value: last24Hours },
+ { label: 'Last 7 days', value: last7Days },
+ { label: 'Active sites', value: activeSites },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+ {/* Site Health Metrics */}
+
+
Site Health Overview
+
+ {(() => {
+ const healthySites = sites.filter(
+ (site) => site.published_deploy?.state === 'ready' && site.ssl_url,
+ ).length;
+ const sslEnabled = sites.filter((site) => !!site.ssl_url).length;
+ const customDomain = sites.filter((site) => !!site.custom_domain).length;
+ const needsAttention = sites.filter(
+ (site) => site.published_deploy?.state === 'error' || !site.published_deploy,
+ ).length;
+ const buildingSites = sites.filter(
+ (site) =>
+ site.published_deploy?.state === 'building' || site.published_deploy?.state === 'processing',
+ ).length;
+
+ return [
+ {
+ label: 'Healthy',
+ value: healthySites,
+ icon: 'i-ph:heart',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'SSL Enabled',
+ value: sslEnabled,
+ icon: 'i-ph:lock',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Custom Domain',
+ value: customDomain,
+ icon: 'i-ph:globe',
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-100 dark:bg-purple-900/20',
+ textColor: 'text-purple-800 dark:text-purple-400',
+ },
+ {
+ label: 'Building',
+ value: buildingSites,
+ icon: 'i-ph:gear',
+ color: 'text-yellow-500',
+ bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
+ textColor: 'text-yellow-800 dark:text-yellow-400',
+ },
+ {
+ label: 'Needs Attention',
+ value: needsAttention,
+ icon: 'i-ph:warning',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900/20',
+ textColor: 'text-red-800 dark:text-red-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+
+
+
+
+ {connection.stats.totalSites} Sites
+
+
+
+ {deploymentCount} Deployments
+
+
+
+ {connection.stats.totalBuilds || 0} Builds
+
+ {lastUpdated && (
+
+
+ Updated {formatDistanceToNow(new Date(lastUpdated))} ago
+
+ )}
+
+ {sites.length > 0 && (
+
+
+
+
+
+
+ Your Sites ({sites.length})
+
+ {sites.length > 8 && (
+
+ )}
+
+
+
+
+ {(isSitesExpanded ? sites : sites.slice(0, 8)).map((site, index) => (
+
{
+ setActiveSiteIndex(index);
+ }}
+ >
+
+
+
+
+ {site.published_deploy?.state === 'ready' ? (
+
+ ) : (
+
+ )}
+
+ {site.published_deploy?.state || 'Unknown'}
+
+
+
+
+
+
+
+
+ {site.published_deploy?.framework && (
+
+
+
{site.published_deploy.framework}
+
+ )}
+ {site.custom_domain && (
+
+ )}
+ {site.branch && (
+
+ )}
+
+
+
+ {activeSiteIndex === index && (
+ <>
+
+
+ {siteActions.map((action) => (
+
+ ))}
+
+
+ {site.published_deploy && (
+
+
+
+
+ Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
+
+
+ {site.published_deploy.branch && (
+
+
+
+ Branch: {site.published_deploy.branch}
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+ {deploys.length > 0 && (
+
+
+
+
+
+ All Deployments ({deploys.length})
+
+ {deploys.length > 10 && (
+
+ )}
+
+
+
+ {(isDeploysExpanded ? deploys : deploys.slice(0, 10)).map((deploy) => (
+
+
+
+
+ {deploy.state === 'ready' ? (
+
+ ) : deploy.state === 'error' ? (
+
+ ) : (
+
+ )}
+
+ {deploy.state}
+
+
+
+
+ {formatDistanceToNow(new Date(deploy.created_at))} ago
+
+
+ {deploy.branch && (
+
+
+
+ Branch: {deploy.branch}
+
+
+ )}
+ {deploy.deploy_url && (
+
+ )}
+
+
+ {deploy.state === 'ready' ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Builds Section */}
+ {connection.stats.builds && connection.stats.builds.length > 0 && (
+
+
+
+
+ Recent Builds ({connection.stats.builds.length})
+
+
+
+ {connection.stats.builds.slice(0, 8).map((build: any) => (
+
+
+
+
+ {build.done ? (
+
+ ) : (
+
+ )}
+
+ {build.done ? 'Completed' : 'Building'}
+
+
+
+
+ {formatDistanceToNow(new Date(build.created_at))} ago
+
+
+ {build.commit_ref && (
+
+
+
+ {build.commit_ref.substring(0, 7)}
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ Netlify Integration
+
+
+
+ {connection.user && (
+
+ )}
+
+
+
+
+ Connect and manage your Netlify sites with advanced deployment controls and site management
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_NETLIFY_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
+
setTokenInput(e.target.value)}
+ placeholder="Enter your Netlify API token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ Connected to Netlify
+
+
+ {renderStats()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx b/app/components/@settings/tabs/netlify/components/NetlifyConnection.tsx
similarity index 81%
rename from app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx
rename to app/components/@settings/tabs/netlify/components/NetlifyConnection.tsx
index 7a0f238..d915087 100644
--- a/app/components/@settings/tabs/connections/netlify/NetlifyConnection.tsx
+++ b/app/components/@settings/tabs/netlify/components/NetlifyConnection.tsx
@@ -16,6 +16,8 @@ import {
LockClosedIcon,
LockOpenIcon,
RocketLaunchIcon,
+ ChartBarIcon,
+ CogIcon,
} from '@heroicons/react/24/outline';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
@@ -73,15 +75,74 @@ export default function NetlifyConnection() {
icon: ArrowPathIcon,
action: async (siteId: string) => {
try {
- const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
+ // Try to get site details first to check for build hooks
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ const errorText = await siteResponse.text();
+
+ if (siteResponse.status === 404) {
+ toast.error('Site not found. This may be a free account limitation.');
+ return;
+ }
+
+ throw new Error(`Failed to get site details: ${errorText}`);
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+
+ // Check if this looks like a free account (limited features)
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // If site has build hooks, try triggering a build instead
+ if (siteData.build_settings && siteData.build_settings.repo_url) {
+ // Try to trigger a build by making a POST to the site's build endpoint
+ const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ clear_cache: true,
+ }),
+ });
+
+ if (buildResponse.ok) {
+ toast.success('Build triggered with cache clear');
+ return;
+ } else if (buildResponse.status === 422) {
+ // Often indicates free account limitation
+ toast.warning('Build trigger failed. This feature may not be available on free accounts.');
+ return;
+ }
+ }
+
+ // Fallback: Try the standard cache purge endpoint
+ const cacheResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/purge_cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
- if (!response.ok) {
- throw new Error('Failed to clear cache');
+ if (!cacheResponse.ok) {
+ if (cacheResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.warning('Cache purge not available on free accounts. Try triggering a build instead.');
+ } else {
+ toast.error('Cache purge endpoint not found. This feature may not be available.');
+ }
+
+ return;
+ }
+
+ const errorText = await cacheResponse.text();
+ throw new Error(`Cache purge failed: ${errorText}`);
}
toast.success('Site cache cleared successfully');
@@ -91,6 +152,174 @@ export default function NetlifyConnection() {
}
},
},
+ {
+ name: 'Manage Environment',
+ icon: CogIcon,
+ action: async (siteId: string) => {
+ try {
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // Get environment variables
+ const envResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/env`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (envResponse.ok) {
+ const envVars = (await envResponse.json()) as any[];
+ toast.success(`Environment variables loaded: ${envVars.length} variables`);
+ } else if (envResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Environment variables management is limited on free accounts');
+ } else {
+ toast.info('Site has no environment variables configured');
+ }
+ } else {
+ const errorText = await envResponse.text();
+ toast.error(`Failed to load environment variables: ${errorText}`);
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load environment variables: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'Trigger Build',
+ icon: RocketLaunchIcon,
+ action: async (siteId: string) => {
+ try {
+ const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!buildResponse.ok) {
+ throw new Error('Failed to trigger build');
+ }
+
+ const buildData = (await buildResponse.json()) as any;
+ toast.success(`Build triggered successfully! ID: ${buildData.id}`);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to trigger build: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: CodeBracketIcon,
+ action: async (siteId: string) => {
+ try {
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ const functionsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/functions`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (functionsResponse.ok) {
+ const functions = (await functionsResponse.json()) as any[];
+ toast.success(`Site has ${functions.length} serverless functions`);
+ } else if (functionsResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Functions may be limited or unavailable on free accounts');
+ } else {
+ toast.info('Site has no serverless functions');
+ }
+ } else {
+ const errorText = await functionsResponse.text();
+ toast.error(`Failed to load functions: ${errorText}`);
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load functions: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'Site Analytics',
+ icon: ChartBarIcon,
+ action: async (siteId: string) => {
+ try {
+ // Get site info first to check account type
+ const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!siteResponse.ok) {
+ throw new Error('Failed to get site details');
+ }
+
+ const siteData = (await siteResponse.json()) as any;
+ const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter';
+
+ // Get site traffic data (if available)
+ const analyticsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/traffic`, {
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (analyticsResponse.ok) {
+ await analyticsResponse.json(); // Analytics data received
+ toast.success('Site analytics loaded successfully');
+ } else if (analyticsResponse.status === 404) {
+ if (isFreeAccount) {
+ toast.info('Analytics not available on free accounts. Showing basic site info instead.');
+ }
+
+ // Fallback to basic site info
+ toast.info(`Site: ${siteData.name} - Status: ${siteData.state || 'Unknown'}`);
+ } else {
+ const errorText = await analyticsResponse.text();
+
+ if (isFreeAccount) {
+ toast.info(
+ 'Analytics unavailable on free accounts. Site info: ' +
+ `${siteData.name} (${siteData.state || 'Unknown'})`,
+ );
+ } else {
+ toast.error(`Failed to load analytics: ${errorText}`);
+ }
+ }
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to load site analytics: ${error}`);
+ }
+ },
+ },
{
name: 'Delete Site',
icon: TrashIcon,
diff --git a/app/components/@settings/tabs/connections/netlify/index.ts b/app/components/@settings/tabs/netlify/components/index.ts
similarity index 100%
rename from app/components/@settings/tabs/connections/netlify/index.ts
rename to app/components/@settings/tabs/netlify/components/index.ts
diff --git a/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx b/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx
deleted file mode 100644
index 5e0f611..0000000
--- a/app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx
+++ /dev/null
@@ -1,556 +0,0 @@
-import React, { useEffect, useState, useCallback, useMemo } from 'react';
-import { Switch } from '~/components/ui/Switch';
-import { Card, CardContent, CardHeader } from '~/components/ui/Card';
-import { Button } from '~/components/ui/Button';
-import { useSettings } from '~/lib/hooks/useSettings';
-import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
-import type { IProviderConfig } from '~/types/model';
-import { logStore } from '~/lib/stores/logs';
-import { providerBaseUrlEnvKeys } from '~/utils/constants';
-import { useToast } from '~/components/ui/use-toast';
-import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
-import ErrorBoundary from './ErrorBoundary';
-import { ModelCardSkeleton } from './LoadingSkeleton';
-import SetupGuide from './SetupGuide';
-import StatusDashboard from './StatusDashboard';
-import ProviderCard from './ProviderCard';
-import ModelCard from './ModelCard';
-import { OLLAMA_API_URL } from './types';
-import type { OllamaModel, LMStudioModel } from './types';
-import { Cpu, Server, BookOpen, Activity, PackageOpen, Monitor, Loader2, RotateCw, ExternalLink } from 'lucide-react';
-
-// Type definitions
-type ViewMode = 'dashboard' | 'guide' | 'status';
-
-export default function LocalProvidersTab() {
- const { providers, updateProviderSettings } = useSettings();
- const [viewMode, setViewMode] = useState('dashboard');
- const [editingProvider, setEditingProvider] = useState(null);
- const [ollamaModels, setOllamaModels] = useState([]);
- const [lmStudioModels, setLMStudioModels] = useState([]);
- const [isLoadingModels, setIsLoadingModels] = useState(false);
- const [isLoadingLMStudioModels, setIsLoadingLMStudioModels] = useState(false);
- const { toast } = useToast();
- const { startMonitoring, stopMonitoring } = useLocalModelHealth();
-
- // Memoized filtered providers to prevent unnecessary re-renders
- const filteredProviders = useMemo(() => {
- return Object.entries(providers || {})
- .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
- .map(([key, value]) => {
- const provider = value as IProviderConfig;
- const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
- const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
-
- // Set default base URLs for local providers
- let defaultBaseUrl = provider.settings.baseUrl || envUrl;
-
- if (!defaultBaseUrl) {
- if (key === 'Ollama') {
- defaultBaseUrl = 'http://127.0.0.1:11434';
- } else if (key === 'LMStudio') {
- defaultBaseUrl = 'http://127.0.0.1:1234';
- }
- }
-
- return {
- name: key,
- settings: {
- ...provider.settings,
- baseUrl: defaultBaseUrl,
- },
- staticModels: provider.staticModels || [],
- getDynamicModels: provider.getDynamicModels,
- getApiKeyLink: provider.getApiKeyLink,
- labelForGetApiKey: provider.labelForGetApiKey,
- icon: provider.icon,
- } as IProviderConfig;
- })
- .sort((a, b) => {
- // Custom sort: Ollama first, then LMStudio, then OpenAILike
- const order = { Ollama: 0, LMStudio: 1, OpenAILike: 2 };
- return (order[a.name as keyof typeof order] || 3) - (order[b.name as keyof typeof order] || 3);
- });
- }, [providers]);
-
- const categoryEnabled = useMemo(() => {
- return filteredProviders.length > 0 && filteredProviders.every((p) => p.settings.enabled);
- }, [filteredProviders]);
-
- // Start/stop health monitoring for enabled providers
- useEffect(() => {
- filteredProviders.forEach((provider) => {
- const baseUrl = provider.settings.baseUrl;
-
- if (provider.settings.enabled && baseUrl) {
- console.log(`[LocalProvidersTab] Starting monitoring for ${provider.name} at ${baseUrl}`);
- startMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl);
- } else if (!provider.settings.enabled && baseUrl) {
- console.log(`[LocalProvidersTab] Stopping monitoring for ${provider.name} at ${baseUrl}`);
- stopMonitoring(provider.name as 'Ollama' | 'LMStudio' | 'OpenAILike', baseUrl);
- }
- });
- }, [filteredProviders, startMonitoring, stopMonitoring]);
-
- // Fetch Ollama models when enabled
- useEffect(() => {
- const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
-
- if (ollamaProvider?.settings.enabled) {
- fetchOllamaModels();
- }
- }, [filteredProviders]);
-
- // Fetch LM Studio models when enabled
- useEffect(() => {
- const lmStudioProvider = filteredProviders.find((p) => p.name === 'LMStudio');
-
- if (lmStudioProvider?.settings.enabled && lmStudioProvider.settings.baseUrl) {
- fetchLMStudioModels(lmStudioProvider.settings.baseUrl);
- }
- }, [filteredProviders]);
-
- const fetchOllamaModels = async () => {
- try {
- setIsLoadingModels(true);
-
- const response = await fetch(`${OLLAMA_API_URL}/api/tags`);
-
- if (!response.ok) {
- throw new Error('Failed to fetch models');
- }
-
- const data = (await response.json()) as { models: OllamaModel[] };
- setOllamaModels(
- data.models.map((model) => ({
- ...model,
- status: 'idle' as const,
- })),
- );
- } catch {
- console.error('Error fetching Ollama models');
- } finally {
- setIsLoadingModels(false);
- }
- };
-
- const fetchLMStudioModels = async (baseUrl: string) => {
- try {
- setIsLoadingLMStudioModels(true);
-
- const response = await fetch(`${baseUrl}/v1/models`);
-
- if (!response.ok) {
- throw new Error('Failed to fetch LM Studio models');
- }
-
- const data = (await response.json()) as { data: LMStudioModel[] };
- setLMStudioModels(data.data || []);
- } catch {
- console.error('Error fetching LM Studio models');
- setLMStudioModels([]);
- } finally {
- setIsLoadingLMStudioModels(false);
- }
- };
-
- const handleToggleCategory = useCallback(
- async (enabled: boolean) => {
- filteredProviders.forEach((provider) => {
- updateProviderSettings(provider.name, { ...provider.settings, enabled });
- });
- toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
- },
- [filteredProviders, updateProviderSettings, toast],
- );
-
- const handleToggleProvider = useCallback(
- (provider: IProviderConfig, enabled: boolean) => {
- updateProviderSettings(provider.name, {
- ...provider.settings,
- enabled,
- });
-
- logStore.logProvider(`Provider ${provider.name} ${enabled ? 'enabled' : 'disabled'}`, {
- provider: provider.name,
- });
- toast(`${provider.name} ${enabled ? 'enabled' : 'disabled'}`);
- },
- [updateProviderSettings, toast],
- );
-
- const handleUpdateBaseUrl = useCallback(
- (provider: IProviderConfig, newBaseUrl: string) => {
- updateProviderSettings(provider.name, {
- ...provider.settings,
- baseUrl: newBaseUrl,
- });
- toast(`${provider.name} base URL updated`);
- },
- [updateProviderSettings, toast],
- );
-
- const handleUpdateOllamaModel = async (modelName: string) => {
- try {
- setOllamaModels((prev) => prev.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
-
- const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: modelName }),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to update ${modelName}`);
- }
-
- // Handle streaming response
- const reader = response.body?.getReader();
-
- if (!reader) {
- throw new Error('No response reader available');
- }
-
- while (true) {
- const { done, value } = await reader.read();
-
- if (done) {
- break;
- }
-
- const text = new TextDecoder().decode(value);
- const lines = text.split('\n').filter(Boolean);
-
- for (const line of lines) {
- try {
- const data = JSON.parse(line);
-
- if (data.status && data.completed && data.total) {
- setOllamaModels((current) =>
- current.map((m) =>
- m.name === modelName
- ? {
- ...m,
- progress: {
- current: data.completed,
- total: data.total,
- status: data.status,
- },
- }
- : m,
- ),
- );
- }
- } catch {
- // Ignore parsing errors
- }
- }
- }
-
- setOllamaModels((prev) =>
- prev.map((m) => (m.name === modelName ? { ...m, status: 'updated', progress: undefined } : m)),
- );
- toast(`Successfully updated ${modelName}`);
- } catch {
- setOllamaModels((prev) =>
- prev.map((m) => (m.name === modelName ? { ...m, status: 'error', progress: undefined } : m)),
- );
- toast(`Failed to update ${modelName}`, { type: 'error' });
- }
- };
-
- const handleDeleteOllamaModel = async (modelName: string) => {
- if (!window.confirm(`Are you sure you want to delete ${modelName}?`)) {
- return;
- }
-
- try {
- const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: modelName }),
- });
-
- if (!response.ok) {
- throw new Error(`Failed to delete ${modelName}`);
- }
-
- setOllamaModels((current) => current.filter((m) => m.name !== modelName));
- toast(`Deleted ${modelName}`);
- } catch {
- toast(`Failed to delete ${modelName}`, { type: 'error' });
- }
- };
-
- // Render different views based on viewMode
- if (viewMode === 'guide') {
- return (
-
- setViewMode('dashboard')} />
-
- );
- }
-
- if (viewMode === 'status') {
- return (
-
- setViewMode('dashboard')} />
-
- );
- }
-
- return (
-
-
- {/* Header */}
-
-
-
-
-
-
-
Local AI Providers
-
Configure and manage your local AI models
-
-
-
-
- Enable All
-
-
-
-
-
-
-
-
-
- {/* Provider Cards */}
-
- {filteredProviders.map((provider) => (
-
-
handleToggleProvider(provider, enabled)}
- onUpdateBaseUrl={(url) => handleUpdateBaseUrl(provider, url)}
- isEditing={editingProvider === provider.name}
- onStartEditing={() => setEditingProvider(provider.name)}
- onStopEditing={() => setEditingProvider(null)}
- />
-
- {/* Ollama Models Section */}
- {provider.name === 'Ollama' && provider.settings.enabled && (
-
-
-
-
-
-
-
-
- {isLoadingModels ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : ollamaModels.length === 0 ? (
-
- ) : (
-
- {ollamaModels.map((model) => (
- handleUpdateOllamaModel(model.name)}
- onDelete={() => handleDeleteOllamaModel(model.name)}
- />
- ))}
-
- )}
-
-
- )}
-
- {/* LM Studio Models Section */}
- {provider.name === 'LMStudio' && provider.settings.enabled && (
-
-
-
-
-
-
Available Models
-
-
-
-
-
- {isLoadingLMStudioModels ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : lmStudioModels.length === 0 ? (
-
-
-
No Models Available
-
- Make sure LM Studio is running with the local server started and CORS enabled.
-
-
-
- ) : (
-
- {lmStudioModels.map((model) => (
-
-
-
-
-
- {model.id}
-
-
- Available
-
-
-
-
-
- {model.object}
-
-
-
-
Owned by: {model.owned_by}
-
- {model.created && (
-
-
-
Created: {new Date(model.created * 1000).toLocaleDateString()}
-
- )}
-
-
-
-
- ))}
-
- )}
-
-
- )}
-
- ))}
-
-
- {filteredProviders.length === 0 && (
-
-
-
- No Local Providers Available
-
- Local providers will appear here when they're configured in the system.
-
-
-
- )}
-
-
- );
-}
diff --git a/app/components/@settings/tabs/supabase/SupabaseTab.tsx b/app/components/@settings/tabs/supabase/SupabaseTab.tsx
new file mode 100644
index 0000000..cec555a
--- /dev/null
+++ b/app/components/@settings/tabs/supabase/SupabaseTab.tsx
@@ -0,0 +1,1089 @@
+import React, { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { useStore } from '@nanostores/react';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import {
+ supabaseConnection,
+ isConnecting,
+ isFetchingStats,
+ isFetchingApiKeys,
+ updateSupabaseConnection,
+ fetchSupabaseStats,
+ fetchProjectApiKeys,
+ initializeSupabaseConnection,
+ type SupabaseProject,
+} from '~/lib/stores/supabase';
+
+interface ConnectionTestResult {
+ status: 'success' | 'error' | 'testing';
+ message: string;
+ timestamp?: number;
+}
+
+interface ProjectAction {
+ name: string;
+ icon: string;
+ action: (projectId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Supabase logo SVG component
+const SupabaseLogo = () => (
+
+);
+
+export default function SupabaseTab() {
+ const connection = useStore(supabaseConnection);
+ const connecting = useStore(isConnecting);
+ const fetchingStats = useStore(isFetchingStats);
+ const fetchingApiKeys = useStore(isFetchingApiKeys);
+
+ const [tokenInput, setTokenInput] = useState('');
+ const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
+ const [connectionTest, setConnectionTest] = useState(null);
+ const [isProjectActionLoading, setIsProjectActionLoading] = useState(false);
+ const [selectedProjectId, setSelectedProjectId] = useState('');
+
+ // Connection testing function - uses server-side API to test environment token
+ const testConnection = async () => {
+ setConnectionTest({
+ status: 'testing',
+ message: 'Testing connection...',
+ });
+
+ try {
+ const response = await fetch('/api/supabase-user', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as any;
+ setConnectionTest({
+ status: 'success',
+ message: `Connected successfully using environment token. Found ${data.projects?.length || 0} projects`,
+ timestamp: Date.now(),
+ });
+ } else {
+ const errorData = (await response.json().catch(() => ({}))) as { error?: string };
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${errorData.error || `${response.status} ${response.statusText}`}`,
+ timestamp: Date.now(),
+ });
+ }
+ } catch (error) {
+ setConnectionTest({
+ status: 'error',
+ message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ timestamp: Date.now(),
+ });
+ }
+ };
+
+ // Project actions
+ const projectActions: ProjectAction[] = [
+ {
+ name: 'Get API Keys',
+ icon: 'i-ph:key',
+ action: async (projectId: string) => {
+ try {
+ await fetchProjectApiKeys(projectId, connection.token);
+ toast.success('API keys fetched successfully');
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to fetch API keys: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'View Dashboard',
+ icon: 'i-ph:layout',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}`, '_blank');
+ },
+ },
+ {
+ name: 'View Database',
+ icon: 'i-ph:database',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/editor`, '_blank');
+ },
+ },
+ {
+ name: 'View Auth',
+ icon: 'i-ph:user-circle',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/auth/users`, '_blank');
+ },
+ },
+ {
+ name: 'View Storage',
+ icon: 'i-ph:folder',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/storage/buckets`, '_blank');
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: 'i-ph:code',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/functions`, '_blank');
+ },
+ },
+ {
+ name: 'View Logs',
+ icon: 'i-ph:scroll',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/logs`, '_blank');
+ },
+ },
+ {
+ name: 'View Settings',
+ icon: 'i-ph:gear',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/settings`, '_blank');
+ },
+ },
+ {
+ name: 'View API Docs',
+ icon: 'i-ph:book',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/api`, '_blank');
+ },
+ },
+ {
+ name: 'View Realtime',
+ icon: 'i-ph:radio',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/realtime`, '_blank');
+ },
+ },
+ {
+ name: 'View Edge Functions',
+ icon: 'i-ph:terminal',
+ action: async (projectId: string) => {
+ window.open(`https://supabase.com/dashboard/project/${projectId}/functions`, '_blank');
+ },
+ },
+ ];
+
+ // Initialize connection on component mount - check server-side token first
+ useEffect(() => {
+ const initializeConnection = async () => {
+ try {
+ // First try to initialize using server-side token
+ await initializeSupabaseConnection();
+
+ // If no connection was established, the user will need to manually enter a token
+ const currentState = supabaseConnection.get();
+
+ if (!currentState.user) {
+ console.log('No server-side Supabase token available, manual connection required');
+ }
+ } catch (error) {
+ console.error('Failed to initialize Supabase connection:', error);
+ }
+ };
+ initializeConnection();
+ }, []);
+
+ useEffect(() => {
+ const fetchProjects = async () => {
+ if (connection.user && connection.token && !connection.stats) {
+ await fetchSupabaseStats(connection.token);
+ }
+ };
+ fetchProjects();
+ }, [connection.user, connection.token]);
+
+ const handleConnect = async () => {
+ if (!tokenInput) {
+ toast.error('Please enter a Supabase access token');
+ return;
+ }
+
+ isConnecting.set(true);
+
+ try {
+ await fetchSupabaseStats(tokenInput);
+ updateSupabaseConnection({
+ token: tokenInput,
+ isConnected: true,
+ });
+ toast.success('Successfully connected to Supabase');
+ setTokenInput('');
+ } catch (error) {
+ console.error('Auth error:', error);
+ toast.error('Failed to connect to Supabase');
+ updateSupabaseConnection({ user: null, token: '' });
+ } finally {
+ isConnecting.set(false);
+ }
+ };
+
+ const handleDisconnect = () => {
+ updateSupabaseConnection({
+ user: null,
+ token: '',
+ stats: undefined,
+ selectedProjectId: undefined,
+ isConnected: false,
+ project: undefined,
+ credentials: undefined,
+ });
+ setConnectionTest(null);
+ setSelectedProjectId('');
+ toast.success('Disconnected from Supabase');
+ };
+
+ const handleProjectAction = async (projectId: string, action: ProjectAction) => {
+ if (action.requiresConfirmation) {
+ if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
+ return;
+ }
+ }
+
+ setIsProjectActionLoading(true);
+ await action.action(projectId);
+ setIsProjectActionLoading(false);
+ };
+
+ const handleProjectSelect = async (projectId: string) => {
+ setSelectedProjectId(projectId);
+ updateSupabaseConnection({ selectedProjectId: projectId });
+
+ if (projectId && connection.token) {
+ try {
+ await fetchProjectApiKeys(projectId, connection.token);
+ } catch (error) {
+ console.error('Failed to fetch API keys:', error);
+ }
+ }
+ };
+
+ const renderProjects = () => {
+ if (fetchingStats) {
+ return (
+
+
+ Fetching Supabase projects...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Your Projects ({connection.stats?.totalProjects || 0})
+
+
+
+
+
+
+
+ {/* Supabase Overview Dashboard */}
+ {connection.stats?.projects?.length ? (
+
+
Supabase Overview
+
+
+
+ {connection.stats.totalProjects}
+
+
Total Projects
+
+
+
+ {connection.stats.projects.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY').length}
+
+
Active Projects
+
+
+
+ {new Set(connection.stats.projects.map((p: SupabaseProject) => p.region)).size}
+
+
Regions Used
+
+
+
+ {connection.stats.projects.filter((p: SupabaseProject) => p.status !== 'ACTIVE_HEALTHY').length}
+
+
Inactive Projects
+
+
+
+ ) : null}
+
+ {connection.stats?.projects?.length ? (
+
+ {connection.stats.projects.map((project: SupabaseProject) => (
+
handleProjectSelect(project.id)}
+ >
+
+
+
+
+ {project.name}
+
+
+
+
+ {project.region}
+
+
•
+
+
+ {new Date(project.created_at).toLocaleDateString()}
+
+
•
+
+
+ {project.status.replace('_', ' ')}
+
+
+
+ {/* Project Details Grid */}
+
+
+
+ {project.stats?.database?.tables ?? '--'}
+
+
+
+
+
+ {project.stats?.storage?.buckets ?? '--'}
+
+
+
+
+
+ {project.stats?.functions?.deployed ?? '--'}
+
+
+
+
+
+ {project.stats?.database?.size_mb ? `${project.stats.database.size_mb} MB` : '--'}
+
+
+
+
+
+
+
+ {selectedProjectId === project.id && (
+
+
+ {projectActions.map((action) => (
+
+ ))}
+
+
+ {/* Project Details */}
+
+
+
+
+ Database Schema
+
+
+
+ Tables:
+ {project.stats?.database?.tables ?? '--'}
+
+
+ Views:
+ {project.stats?.database?.views ?? '--'}
+
+
+ Functions:
+ {project.stats?.database?.functions ?? '--'}
+
+
+ Size:
+
+ {project.stats?.database?.size_mb ? `${project.stats.database.size_mb} MB` : '--'}
+
+
+
+
+
+
+
+
+ Storage
+
+
+
+ Buckets:
+ {project.stats?.storage?.buckets ?? '--'}
+
+
+ Files:
+ {project.stats?.storage?.files ?? '--'}
+
+
+ Used:
+
+ {project.stats?.storage?.used_gb ? `${project.stats.storage.used_gb} GB` : '--'}
+
+
+
+ Available:
+
+ {project.stats?.storage?.available_gb
+ ? `${project.stats.storage.available_gb} GB`
+ : '--'}
+
+
+
+
+
+
+ {connection.credentials && (
+
+
+
+ Project Credentials
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+ No projects found in your Supabase account
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ Supabase Integration
+
+
+
+ {connection.user && (
+
+ )}
+
+
+
+
+ Connect and manage your Supabase projects with database access, authentication, and storage controls
+
+
+ {/* Connection Test Results */}
+ {connectionTest && (
+
+
+ {connectionTest.status === 'success' && (
+
+ )}
+ {connectionTest.status === 'error' && (
+
+ )}
+ {connectionTest.status === 'testing' && (
+
+ )}
+
+ {connectionTest.message}
+
+
+ {connectionTest.timestamp && (
+ {new Date(connectionTest.timestamp).toLocaleString()}
+ )}
+
+ )}
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_SUPABASE_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
+
setTokenInput(e.target.value)}
+ disabled={connecting}
+ placeholder="Enter your Supabase access token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ Connected to Supabase
+
+
+
+
+ {connection.user && (
+
+
+
+
+
{connection.user.email}
+
+ {connection.user.role} • Member since{' '}
+ {new Date(connection.user.created_at).toLocaleDateString()}
+
+
+
+
+ {connection.stats?.totalProjects || 0} Projects
+
+
+
+ {new Set(connection.stats?.projects?.map((p: SupabaseProject) => p.region) || []).size}{' '}
+ Regions
+
+
+
+ {connection.stats?.projects?.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY')
+ .length || 0}{' '}
+ Active
+
+
+
+
+
+ {/* Advanced Analytics */}
+
+
Performance Analytics
+
+
+
+
+ Database Health
+
+
+ {(() => {
+ const totalProjects = connection.stats?.totalProjects || 0;
+ const activeProjects =
+ connection.stats?.projects?.filter((p: SupabaseProject) => p.status === 'ACTIVE_HEALTHY')
+ .length || 0;
+ const healthRate =
+ totalProjects > 0 ? Math.round((activeProjects / totalProjects) * 100) : 0;
+ const avgTablesPerProject =
+ totalProjects > 0
+ ? Math.round(
+ (connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.database?.tables || 0),
+ 0,
+ ) || 0) / totalProjects,
+ )
+ : 0;
+
+ return [
+ { label: 'Health Rate', value: `${healthRate}%` },
+ { label: 'Active Projects', value: activeProjects },
+ { label: 'Avg Tables/Project', value: avgTablesPerProject },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Auth & Security
+
+
+ {(() => {
+ const totalProjects = connection.stats?.totalProjects || 0;
+ const projectsWithAuth =
+ connection.stats?.projects?.filter((p) => p.stats?.auth?.users !== undefined).length || 0;
+ const authEnabledRate =
+ totalProjects > 0 ? Math.round((projectsWithAuth / totalProjects) * 100) : 0;
+ const totalUsers =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.auth?.users || 0), 0) || 0;
+
+ return [
+ { label: 'Auth Enabled', value: `${authEnabledRate}%` },
+ { label: 'Total Users', value: totalUsers },
+ {
+ label: 'Avg Users/Project',
+ value: totalProjects > 0 ? Math.round(totalUsers / totalProjects) : 0,
+ },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Regional Distribution
+
+
+ {(() => {
+ const regions =
+ connection.stats?.projects?.reduce(
+ (acc, p: SupabaseProject) => {
+ acc[p.region] = (acc[p.region] || 0) + 1;
+ return acc;
+ },
+ {} as Record
,
+ ) || {};
+
+ return Object.entries(regions)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 3)
+ .map(([region, count]) => ({ label: region.toUpperCase(), value: count }));
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+ {/* Resource Utilization */}
+
+
Resource Overview
+
+ {(() => {
+ const totalDatabase =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database?.size_mb || 0), 0) ||
+ 0;
+ const totalStorage =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage?.used_gb || 0), 0) ||
+ 0;
+ const totalFunctions =
+ connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.functions?.deployed || 0),
+ 0,
+ ) || 0;
+ const totalTables =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database?.tables || 0), 0) ||
+ 0;
+ const totalBuckets =
+ connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage?.buckets || 0), 0) ||
+ 0;
+
+ return [
+ {
+ label: 'Database',
+ value: totalDatabase > 0 ? `${totalDatabase} MB` : '--',
+ icon: 'i-ph:database',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Storage',
+ value: totalStorage > 0 ? `${totalStorage} GB` : '--',
+ icon: 'i-ph:folder',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'Functions',
+ value: totalFunctions,
+ icon: 'i-ph:code',
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-100 dark:bg-purple-900/20',
+ textColor: 'text-purple-800 dark:text-purple-400',
+ },
+ {
+ label: 'Tables',
+ value: totalTables,
+ icon: 'i-ph:table',
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-100 dark:bg-orange-900/20',
+ textColor: 'text-orange-800 dark:text-orange-400',
+ },
+ {
+ label: 'Buckets',
+ value: totalBuckets,
+ icon: 'i-ph:archive',
+ color: 'text-teal-500',
+ bgColor: 'bg-teal-100 dark:bg-teal-900/20',
+ textColor: 'text-teal-800 dark:text-teal-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+
+ {/* Usage Metrics */}
+
+
+
+
+
+ Tables:{' '}
+ {connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.database?.tables || 0), 0) ||
+ '--'}
+
+
+ Size:{' '}
+ {(() => {
+ const totalSize =
+ connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.database?.size_mb || 0),
+ 0,
+ ) || 0;
+ return totalSize > 0 ? `${totalSize} MB` : '--';
+ })()}
+
+
+
+
+
+
+
+ Buckets:{' '}
+ {connection.stats?.projects?.reduce((sum, p) => sum + (p.stats?.storage?.buckets || 0), 0) ||
+ '--'}
+
+
+ Used:{' '}
+ {(() => {
+ const totalUsed =
+ connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.storage?.used_gb || 0),
+ 0,
+ ) || 0;
+ return totalUsed > 0 ? `${totalUsed} GB` : '--';
+ })()}
+
+
+
+
+
+
+
+ Deployed:{' '}
+ {connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.functions?.deployed || 0),
+ 0,
+ ) || '--'}
+
+
+ Invocations:{' '}
+ {connection.stats?.projects?.reduce(
+ (sum, p) => sum + (p.stats?.functions?.invocations || 0),
+ 0,
+ ) || '--'}
+
+
+
+
+
+ )}
+
+ {renderProjects()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/vercel/VercelTab.tsx b/app/components/@settings/tabs/vercel/VercelTab.tsx
new file mode 100644
index 0000000..0aba33c
--- /dev/null
+++ b/app/components/@settings/tabs/vercel/VercelTab.tsx
@@ -0,0 +1,909 @@
+import React, { useEffect, useState, useMemo, useCallback } from 'react';
+import { motion } from 'framer-motion';
+import { toast } from 'react-toastify';
+import { useStore } from '@nanostores/react';
+import { logStore } from '~/lib/stores/logs';
+import type { VercelUserResponse } from '~/types/vercel';
+import { classNames } from '~/utils/classNames';
+import { Button } from '~/components/ui/Button';
+import { ServiceHeader, ConnectionTestIndicator } from '~/components/@settings/shared/service-integration';
+import { useConnectionTest } from '~/lib/hooks';
+import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
+import Cookies from 'js-cookie';
+import {
+ vercelConnection,
+ isConnecting,
+ isFetchingStats,
+ updateVercelConnection,
+ fetchVercelStats,
+ fetchVercelStatsViaAPI,
+ initializeVercelConnection,
+} from '~/lib/stores/vercel';
+
+interface ProjectAction {
+ name: string;
+ icon: string;
+ action: (projectId: string) => Promise;
+ requiresConfirmation?: boolean;
+ variant?: 'default' | 'destructive' | 'outline';
+}
+
+// Vercel logo SVG component
+const VercelLogo = () => (
+
+);
+
+export default function VercelTab() {
+ const connection = useStore(vercelConnection);
+ const connecting = useStore(isConnecting);
+ const fetchingStats = useStore(isFetchingStats);
+ const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
+ const [isProjectActionLoading, setIsProjectActionLoading] = useState(false);
+
+ // Use shared connection test hook
+ const {
+ testResult: connectionTest,
+ testConnection,
+ isTestingConnection,
+ } = useConnectionTest({
+ testEndpoint: '/api/vercel-user',
+ serviceName: 'Vercel',
+ getUserIdentifier: (data: VercelUserResponse) =>
+ data.username || data.user?.username || data.email || data.user?.email || 'Vercel User',
+ });
+
+ // Memoize project actions to prevent unnecessary re-renders
+ const projectActions: ProjectAction[] = useMemo(
+ () => [
+ {
+ name: 'Redeploy',
+ icon: 'i-ph:arrows-clockwise',
+ action: async (projectId: string) => {
+ try {
+ const response = await fetch(`https://api.vercel.com/v1/deployments`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: projectId,
+ target: 'production',
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to redeploy project');
+ }
+
+ toast.success('Project redeployment initiated');
+ await fetchVercelStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to redeploy project: ${error}`);
+ }
+ },
+ },
+ {
+ name: 'View Dashboard',
+ icon: 'i-ph:layout',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}`, '_blank');
+ },
+ },
+ {
+ name: 'View Deployments',
+ icon: 'i-ph:rocket',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/deployments`, '_blank');
+ },
+ },
+ {
+ name: 'View Functions',
+ icon: 'i-ph:code',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/functions`, '_blank');
+ },
+ },
+ {
+ name: 'View Analytics',
+ icon: 'i-ph:chart-bar',
+ action: async (projectId: string) => {
+ const project = connection.stats?.projects.find((p) => p.id === projectId);
+
+ if (project) {
+ window.open(`https://vercel.com/${connection.user?.username}/${project.name}/analytics`, '_blank');
+ }
+ },
+ },
+ {
+ name: 'View Domains',
+ icon: 'i-ph:globe',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/domains`, '_blank');
+ },
+ },
+ {
+ name: 'View Settings',
+ icon: 'i-ph:gear',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/settings`, '_blank');
+ },
+ },
+ {
+ name: 'View Logs',
+ icon: 'i-ph:scroll',
+ action: async (projectId: string) => {
+ window.open(`https://vercel.com/dashboard/${projectId}/logs`, '_blank');
+ },
+ },
+ {
+ name: 'Delete Project',
+ icon: 'i-ph:trash',
+ action: async (projectId: string) => {
+ try {
+ const response = await fetch(`https://api.vercel.com/v1/projects/${projectId}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete project');
+ }
+
+ toast.success('Project deleted successfully');
+ await fetchVercelStats(connection.token);
+ } catch (err: unknown) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ toast.error(`Failed to delete project: ${error}`);
+ }
+ },
+ requiresConfirmation: true,
+ variant: 'destructive',
+ },
+ ],
+ [connection.token],
+ ); // Only re-create when token changes
+
+ // Initialize connection on component mount - check server-side token first
+ useEffect(() => {
+ const initializeConnection = async () => {
+ try {
+ // First try to initialize using server-side token
+ await initializeVercelConnection();
+
+ // If no connection was established, the user will need to manually enter a token
+ const currentState = vercelConnection.get();
+
+ if (!currentState.user) {
+ console.log('No server-side Vercel token available, manual connection required');
+ }
+ } catch (error) {
+ console.error('Failed to initialize Vercel connection:', error);
+ }
+ };
+ initializeConnection();
+ }, []);
+
+ useEffect(() => {
+ const fetchProjects = async () => {
+ if (connection.user) {
+ // Use server-side API if we have a connected user
+ try {
+ await fetchVercelStatsViaAPI(connection.token);
+ } catch {
+ // Fallback to direct API if server-side fails and we have a token
+ if (connection.token) {
+ await fetchVercelStats(connection.token);
+ }
+ }
+ }
+ };
+ fetchProjects();
+ }, [connection.user, connection.token]);
+
+ const handleConnect = async (event: React.FormEvent) => {
+ event.preventDefault();
+ isConnecting.set(true);
+
+ try {
+ const token = connection.token;
+
+ if (!token.trim()) {
+ throw new Error('Token is required');
+ }
+
+ // First test the token directly with Vercel API
+ const testResponse = await fetch('https://api.vercel.com/v2/user', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': 'bolt.diy-app',
+ },
+ });
+
+ if (!testResponse.ok) {
+ if (testResponse.status === 401) {
+ throw new Error('Invalid Vercel token');
+ }
+
+ throw new Error(`Vercel API error: ${testResponse.status}`);
+ }
+
+ const userData = (await testResponse.json()) as VercelUserResponse;
+
+ // Set cookies for server-side API access
+ Cookies.set('VITE_VERCEL_ACCESS_TOKEN', token, { expires: 365 });
+
+ // Normalize the user data structure
+ const normalizedUser = userData.user || {
+ id: userData.id || '',
+ username: userData.username || '',
+ email: userData.email || '',
+ name: userData.name || '',
+ avatar: userData.avatar,
+ };
+
+ updateVercelConnection({
+ user: normalizedUser,
+ token,
+ });
+
+ await fetchVercelStats(token);
+ toast.success('Successfully connected to Vercel');
+ } catch (error) {
+ console.error('Auth error:', error);
+ logStore.logError('Failed to authenticate with Vercel', { error });
+
+ const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Vercel';
+ toast.error(errorMessage);
+ updateVercelConnection({ user: null, token: '' });
+ } finally {
+ isConnecting.set(false);
+ }
+ };
+
+ const handleDisconnect = () => {
+ // Clear Vercel-related cookies
+ Cookies.remove('VITE_VERCEL_ACCESS_TOKEN');
+
+ updateVercelConnection({ user: null, token: '' });
+ toast.success('Disconnected from Vercel');
+ };
+
+ const handleProjectAction = useCallback(async (projectId: string, action: ProjectAction) => {
+ if (action.requiresConfirmation) {
+ if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
+ return;
+ }
+ }
+
+ setIsProjectActionLoading(true);
+ await action.action(projectId);
+ setIsProjectActionLoading(false);
+ }, []);
+
+ const renderProjects = useCallback(() => {
+ if (fetchingStats) {
+ return (
+
+
+ Fetching Vercel projects...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Your Projects ({connection.stats?.totalProjects || 0})
+
+
+
+
+
+
+
+ {/* Vercel Overview Dashboard */}
+ {connection.stats?.projects?.length ? (
+
+
Vercel Overview
+
+
+
+ {connection.stats.totalProjects}
+
+
Total Projects
+
+
+
+ {
+ connection.stats.projects.filter(
+ (p) => p.targets?.production?.alias && p.targets.production.alias.length > 0,
+ ).length
+ }
+
+
Deployed Projects
+
+
+
+ {new Set(connection.stats.projects.map((p) => p.framework).filter(Boolean)).size}
+
+
Frameworks Used
+
+
+
+ {connection.stats.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length}
+
+
Active Deployments
+
+
+
+ ) : null}
+
+ {/* Performance Analytics */}
+ {connection.stats?.projects?.length ? (
+
+
Performance Analytics
+
+
+
+
+ Deployment Health
+
+
+ {(() => {
+ const totalDeployments = connection.stats.projects.reduce(
+ (sum, p) => sum + (p.latestDeployments?.length || 0),
+ 0,
+ );
+ const readyDeployments = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'READY',
+ ).length;
+ const errorDeployments = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'ERROR',
+ ).length;
+ const successRate =
+ totalDeployments > 0
+ ? Math.round((readyDeployments / connection.stats.projects.length) * 100)
+ : 0;
+
+ return [
+ { label: 'Success Rate', value: `${successRate}%` },
+ { label: 'Active', value: readyDeployments },
+ { label: 'Failed', value: errorDeployments },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Framework Distribution
+
+
+ {(() => {
+ const frameworks = connection.stats.projects.reduce(
+ (acc, p) => {
+ if (p.framework) {
+ acc[p.framework] = (acc[p.framework] || 0) + 1;
+ }
+
+ return acc;
+ },
+ {} as Record
,
+ );
+
+ return Object.entries(frameworks)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 3)
+ .map(([framework, count]) => ({ label: framework, value: count }));
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+ Activity Summary
+
+
+ {(() => {
+ const now = Date.now();
+ const recentDeployments = connection.stats.projects.filter((p) => {
+ const lastDeploy = p.latestDeployments?.[0]?.created;
+ return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000;
+ }).length;
+ const totalDomains = connection.stats.projects.reduce(
+ (sum, p) => sum + (p.targets?.production?.alias ? p.targets.production.alias.length : 0),
+ 0,
+ );
+ const avgDomainsPerProject =
+ connection.stats.projects.length > 0
+ ? Math.round((totalDomains / connection.stats.projects.length) * 10) / 10
+ : 0;
+
+ return [
+ { label: 'Recent deploys', value: recentDeployments },
+ { label: 'Total domains', value: totalDomains },
+ { label: 'Avg domains/project', value: avgDomainsPerProject },
+ ];
+ })().map((item, idx) => (
+
+ {item.label}:
+ {item.value}
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {/* Project Health Overview */}
+ {connection.stats?.projects?.length ? (
+
+
Project Health Overview
+
+ {(() => {
+ const healthyProjects = connection.stats.projects.filter(
+ (p) =>
+ p.latestDeployments?.[0]?.state === 'READY' && (p.targets?.production?.alias?.length ?? 0) > 0,
+ ).length;
+ const needsAttention = connection.stats.projects.filter(
+ (p) =>
+ p.latestDeployments?.[0]?.state === 'ERROR' || p.latestDeployments?.[0]?.state === 'CANCELED',
+ ).length;
+ const withCustomDomain = connection.stats.projects.filter((p) =>
+ p.targets?.production?.alias?.some((alias: string) => !alias.includes('.vercel.app')),
+ ).length;
+ const buildingProjects = connection.stats.projects.filter(
+ (p) => p.latestDeployments?.[0]?.state === 'BUILDING',
+ ).length;
+
+ return [
+ {
+ label: 'Healthy',
+ value: healthyProjects,
+ icon: 'i-ph:check-circle',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900/20',
+ textColor: 'text-green-800 dark:text-green-400',
+ },
+ {
+ label: 'Custom Domain',
+ value: withCustomDomain,
+ icon: 'i-ph:globe',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900/20',
+ textColor: 'text-blue-800 dark:text-blue-400',
+ },
+ {
+ label: 'Building',
+ value: buildingProjects,
+ icon: 'i-ph:gear',
+ color: 'text-yellow-500',
+ bgColor: 'bg-yellow-100 dark:bg-yellow-900/20',
+ textColor: 'text-yellow-800 dark:text-yellow-400',
+ },
+ {
+ label: 'Issues',
+ value: needsAttention,
+ icon: 'i-ph:warning',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900/20',
+ textColor: 'text-red-800 dark:text-red-400',
+ },
+ ];
+ })().map((metric, index) => (
+
+ ))}
+
+
+ ) : null}
+
+ {connection.stats?.projects?.length ? (
+
+ {connection.stats.projects.map((project) => (
+
+
+
+
+
+ {project.name}
+
+
+
+ {/* Project Details Grid */}
+
+
+
+ {/* Deployments - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Domains - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Team Members - This would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Bandwidth - This would be fetched from API */}
+ --
+
+
+
+
+
+
+ {project.latestDeployments && project.latestDeployments.length > 0 && (
+
+
+ {project.latestDeployments[0].state}
+
+ )}
+ {project.framework && (
+
+
+
+ {project.framework}
+
+
+ )}
+
+
+
+
+
+ {projectActions.map((action) => (
+
+ ))}
+
+
+ ))}
+
+ ) : (
+
+
+ No projects found in your Vercel account
+
+ )}
+
+
+
+ );
+ }, [
+ connection.stats,
+ fetchingStats,
+ isProjectsExpanded,
+ isProjectActionLoading,
+ handleProjectAction,
+ projectActions,
+ ]);
+
+ console.log('connection', connection);
+
+ return (
+
+
testConnection() : undefined}
+ isTestingConnection={isTestingConnection}
+ />
+
+
+
+ {/* Main Connection Component */}
+
+
+ {!connection.user ? (
+
+
+
+
+ Tip: You can also set the{' '}
+
+ VITE_VERCEL_ACCESS_TOKEN
+ {' '}
+ environment variable to connect automatically.
+
+
+
+
+
+
updateVercelConnection({ ...connection, token: e.target.value })}
+ disabled={connecting}
+ placeholder="Enter your Vercel personal access token"
+ className={classNames(
+ 'w-full px-3 py-2 rounded-lg text-sm',
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
+ 'border border-[#E5E5E5] dark:border-[#333333]',
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
+ 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
+ 'disabled:opacity-50',
+ )}
+ />
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ Connected to Vercel
+
+
+
+
+
+
+

+
+
+ {connection.user?.username || 'Vercel User'}
+
+
+ {connection.user?.email || 'No email available'}
+
+
+
+
+ {connection.stats?.totalProjects || 0} Projects
+
+
+
+ {connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
+ 0}{' '}
+ Live
+
+
+
+ {/* Team size would be fetched from API */}
+ --
+
+
+
+
+
+ {/* Usage Metrics */}
+
+
+
+
+
+ Active:{' '}
+ {connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length ||
+ 0}
+
+
Total: {connection.stats?.totalProjects || 0}
+
+
+
+
+
+ {/* Domain usage would be fetched from API */}
+
Custom: --
+
Vercel: --
+
+
+
+
+
+ {/* Usage metrics would be fetched from API */}
+
Bandwidth: --
+
Requests: --
+
+
+
+
+
+ {renderProjects()}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/connections/vercel/VercelConnection.tsx b/app/components/@settings/tabs/vercel/components/VercelConnection.tsx
similarity index 100%
rename from app/components/@settings/tabs/connections/vercel/VercelConnection.tsx
rename to app/components/@settings/tabs/vercel/components/VercelConnection.tsx
diff --git a/app/components/@settings/tabs/connections/vercel/index.ts b/app/components/@settings/tabs/vercel/components/index.ts
similarity index 100%
rename from app/components/@settings/tabs/connections/vercel/index.ts
rename to app/components/@settings/tabs/vercel/components/index.ts
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx
index 098480d..cd1fd75 100644
--- a/app/components/chat/GitCloneButton.tsx
+++ b/app/components/chat/GitCloneButton.tsx
@@ -7,15 +7,14 @@ import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
-// import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
import { classNames } from '~/utils/classNames';
import { Button } from '~/components/ui/Button';
import type { IChatMetadata } from '~/lib/persistence/db';
import { X, Github, GitBranch } from 'lucide-react';
-// Import GitLab and GitHub connections for unified repository access
-import GitLabConnection from '~/components/@settings/tabs/connections/gitlab/GitLabConnection';
-import GitHubConnection from '~/components/@settings/tabs/connections/github/GitHubConnection';
+// Import the new repository selector components
+import { GitHubRepositorySelector } from '~/components/@settings/tabs/github/components/GitHubRepositorySelector';
+import { GitLabRepositorySelector } from '~/components/@settings/tabs/gitlab/components/GitLabRepositorySelector';
const IGNORE_PATTERNS = [
'node_modules/**',
@@ -280,7 +279,7 @@ ${escapeBoltTags(file.content)}