From 4ca535b9d19dd5f47904ffd65469bfc3d8bc1c7d Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:29:12 +0200 Subject: [PATCH] feat: comprehensive service integration refactor with enhanced tabs architecture (#1978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add service tabs refactor with GitHub, GitLab, Supabase, Vercel, and Netlify integration This commit introduces a comprehensive refactor of the connections system, replacing the single connections tab with dedicated service integration tabs: ✨ New Service Tabs: - GitHub Tab: Complete integration with repository management, stats, and API - GitLab Tab: GitLab project integration and management - Supabase Tab: Database project management with comprehensive analytics - Vercel Tab: Project deployment management and monitoring - Netlify Tab: Site deployment and build management 🔧 Supporting Infrastructure: - Enhanced store management for each service with auto-connect via env vars - API routes for secure server-side token handling and data fetching - Updated TypeScript types with missing properties and interfaces - Comprehensive hooks for service connections and state management - Security utilities for API endpoint validation 🎨 UI/UX Improvements: - Individual service tabs with tailored functionality - Motion animations and improved loading states - Connection testing and health monitoring - Advanced analytics dashboards for each service - Consistent design patterns across all service tabs 🛠️ Technical Changes: - Removed legacy connection tab in favor of individual service tabs - Updated tab configuration and routing system - Added comprehensive error handling and loading states - Enhanced type safety with extended interfaces - Implemented environment variable auto-connection features Note: Some TypeScript errors remain and will need to be resolved in follow-up commits. The dev server runs successfully and the service tabs are functional. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: comprehensive service integration refactor with enhanced tabs architecture Major architectural improvements to service integrations: **Service Integration Refactor:** - Complete restructure of service connection tabs (GitHub, GitLab, Vercel, Netlify, Supabase) - Migrated from centralized ConnectionsTab to dedicated service-specific tabs - Added shared service integration components for consistent UX - Implemented auto-connection feature using environment variables **New Components & Architecture:** - ServiceIntegrationLayout for consistent service tab structure - ConnectionStatus, ServiceCard components for reusable UI patterns - BranchSelector component for repository branch management - Enhanced authentication dialogs with improved error handling **API & Backend Enhancements:** - New API endpoints: github-branches, gitlab-branches, gitlab-projects, vercel-user - Enhanced GitLab API service with comprehensive project management - Improved connection testing hooks (useConnectionTest) - Better error handling and rate limiting across all services **Configuration & Environment:** - Updated .env.example with comprehensive service integration guides - Added auto-connection support for all major services - Improved development and production environment configurations - Enhanced tab management with proper service icons **Code Quality & TypeScript:** - Fixed all TypeScript errors across service integration components - Enhanced type definitions for Vercel, Supabase, and other service integrations - Improved type safety with proper optional chaining and type assertions - Better separation of concerns between UI and business logic **Removed Legacy Code:** - Removed redundant connection components and consolidated into service tabs - Cleaned up unused imports and deprecated connection patterns - Streamlined authentication flows across all services This refactor provides a more maintainable, scalable architecture for service integrations while significantly improving the user experience for managing external connections. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: clean up dead code and consolidate utilities - Remove legacy .eslintrc.json (replaced by flat config) - Remove duplicate app/utils/types.ts (unused type definitions) - Remove app/utils/cn.ts and consolidate with classNames utility - Clean up unused ServiceErrorHandler class implementation - Enhance classNames utility to support boolean values - Update GlowingEffect.tsx to use consolidated classNames utility Removes ~150+ lines of unused code while maintaining all functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Simplify terminal health checks and improve project setup Removed aggressive health checking and reconnection logic from TerminalManager to prevent issues with terminal responsiveness. Updated TerminalTabs to remove onReconnect handlers. Enhanced projectCommands utility to generate non-interactive setup commands and detect shadcn projects, improving automation and reliability of project setup. * fix: resolve GitLab deployment issues and enhance GitHub deployment reliability GitLab Deployment Fixes: - Fix COEP header issue for avatar images by adding crossOrigin and referrerPolicy attributes - Implement repository name sanitization to handle special characters and ensure GitLab compliance - Enhance error handling with detailed validation error parsing and user-friendly messages - Add explicit path field and description to project creation requests - Improve URL encoding and project path resolution for proper API calls - Add graceful file commit handling with timeout and error recovery GitHub Deployment Enhancements: - Add comprehensive repository name validation and sanitization - Implement real-time feedback for invalid characters in repository name input - Enhance error handling with specific error types and retry suggestions - Improve user experience with better error messages and validation feedback - Add repository name length limits and character restrictions - Show sanitized name preview to users before submission General Improvements: - Add GitLabAuthDialog component for improved authentication flow - Enhance logging and debugging capabilities for deployment operations - Improve accessibility with proper dialog titles and descriptions - Add better user notifications for name sanitization and validation issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .env.example | 133 +- .env.production | 29 +- .eslintrc.json | 15 - .../@settings/core/ControlPanel.tsx | 36 +- app/components/@settings/core/constants.ts | 57 - app/components/@settings/core/constants.tsx | 108 ++ app/components/@settings/core/types.ts | 27 +- .../shared/components/DraggableTabList.tsx | 163 -- .../@settings/shared/components/TabTile.tsx | 24 +- .../service-integration/ConnectionForm.tsx | 193 +++ .../ConnectionTestIndicator.tsx | 60 + .../shared/service-integration/ErrorState.tsx | 102 ++ .../service-integration/LoadingState.tsx | 94 ++ .../service-integration/ServiceHeader.tsx | 72 + .../shared/service-integration/index.ts | 6 + .../tabs/connections/ConnectionsTab.tsx | 73 - .../tabs/connections/github/AuthDialog.tsx | 153 -- .../connections/github/GitHubConnection.tsx | 276 ---- .../connections/github/RepositoryCard.tsx | 110 -- .../connections/github/RepositoryList.tsx | 144 -- .../tabs/connections/github/StatsDisplay.tsx | 161 -- .../tabs/connections/github/index.ts | 5 - .../connections/gitlab/GitLabConnection.tsx | 389 ----- .../@settings/tabs/github/GitHubTab.tsx | 281 ++++ .../github/components/GitHubAuthDialog.tsx | 173 ++ .../github/components/GitHubCacheManager.tsx | 367 +++++ .../github/components/GitHubConnection.tsx | 233 +++ .../github/components/GitHubErrorBoundary.tsx | 105 ++ .../components/GitHubProgressiveLoader.tsx | 266 ++++ .../components/GitHubRepositoryCard.tsx | 121 ++ .../components/GitHubRepositorySelector.tsx | 312 ++++ .../tabs/github/components/GitHubStats.tsx | 291 ++++ .../github/components/GitHubUserProfile.tsx | 46 + .../shared/GitHubStateIndicators.tsx | 264 ++++ .../components/shared/RepositoryCard.tsx | 361 +++++ .../tabs/github/components/shared/index.ts | 11 + .../@settings/tabs/gitlab/GitLabTab.tsx | 305 ++++ .../gitlab/components/GitLabAuthDialog.tsx | 186 +++ .../gitlab/components/GitLabConnection.tsx | 253 +++ .../components/GitLabRepositorySelector.tsx | 358 +++++ .../components}/RepositoryCard.tsx | 0 .../components}/RepositoryList.tsx | 0 .../components}/StatsDisplay.tsx | 0 .../gitlab => gitlab/components}/index.ts | 0 .../@settings/tabs/netlify/NetlifyTab.tsx | 1393 +++++++++++++++++ .../components}/NetlifyConnection.tsx | 235 ++- .../netlify => netlify/components}/index.ts | 0 .../providers/local/LocalProvidersTab.new.tsx | 556 ------- .../@settings/tabs/supabase/SupabaseTab.tsx | 1089 +++++++++++++ .../@settings/tabs/vercel/VercelTab.tsx | 909 +++++++++++ .../components}/VercelConnection.tsx | 0 .../vercel => vercel/components}/index.ts | 0 app/components/chat/GitCloneButton.tsx | 11 +- .../deploy/GitHubDeploymentDialog.tsx | 157 +- .../deploy/GitLabDeploymentDialog.tsx | 61 +- app/components/ui/BranchSelector.tsx | 270 ++++ app/components/ui/GlowingEffect.tsx | 8 +- .../workbench/terminal/TerminalManager.tsx | 131 +- .../workbench/terminal/TerminalTabs.tsx | 14 - app/lib/hooks/index.ts | 6 + app/lib/hooks/useConnectionTest.ts | 63 + app/lib/hooks/useGitHubAPI.ts | 6 + app/lib/hooks/useGitHubConnection.ts | 250 +++ app/lib/hooks/useGitHubStats.ts | 321 ++++ app/lib/hooks/useGitLabAPI.ts | 7 + app/lib/hooks/useGitLabConnection.ts | 256 +++ app/lib/hooks/useSupabaseConnection.ts | 45 +- app/lib/modules/llm/providers/xai.ts | 6 +- app/lib/security.ts | 245 +++ app/lib/services/githubApiService.ts | 662 ++++---- app/lib/services/gitlabApiService.ts | 101 +- app/lib/stores/github.ts | 136 ++ app/lib/stores/gitlabConnection.ts | 14 +- app/lib/stores/supabase.ts | 35 + app/lib/stores/vercel.ts | 12 + app/lib/utils/serviceErrorHandler.ts | 7 + app/routes/api.github-branches.ts | 166 ++ app/routes/api.github-stats.ts | 198 +++ app/routes/api.github-user.ts | 287 ++++ app/routes/api.gitlab-branches.ts | 143 ++ app/routes/api.gitlab-projects.ts | 105 ++ app/routes/api.netlify-user.ts | 142 ++ app/routes/api.supabase-user.ts | 199 +++ app/routes/api.vercel-user.ts | 161 ++ app/types/GitHub.ts | 23 + app/types/netlify.ts | 2 + app/types/supabase.ts | 23 + app/types/vercel.ts | 31 +- app/utils/classNames.ts | 12 +- app/utils/cn.ts | 6 - app/utils/githubStats.ts | 168 +- app/utils/projectCommands.ts | 48 +- app/utils/types.ts | 21 - tests/preview/smoke.spec.ts | 83 - 94 files changed, 12201 insertions(+), 2986 deletions(-) delete mode 100644 .eslintrc.json delete mode 100644 app/components/@settings/core/constants.ts create mode 100644 app/components/@settings/core/constants.tsx delete mode 100644 app/components/@settings/shared/components/DraggableTabList.tsx create mode 100644 app/components/@settings/shared/service-integration/ConnectionForm.tsx create mode 100644 app/components/@settings/shared/service-integration/ConnectionTestIndicator.tsx create mode 100644 app/components/@settings/shared/service-integration/ErrorState.tsx create mode 100644 app/components/@settings/shared/service-integration/LoadingState.tsx create mode 100644 app/components/@settings/shared/service-integration/ServiceHeader.tsx create mode 100644 app/components/@settings/shared/service-integration/index.ts delete mode 100644 app/components/@settings/tabs/connections/ConnectionsTab.tsx delete mode 100644 app/components/@settings/tabs/connections/github/AuthDialog.tsx delete mode 100644 app/components/@settings/tabs/connections/github/GitHubConnection.tsx delete mode 100644 app/components/@settings/tabs/connections/github/RepositoryCard.tsx delete mode 100644 app/components/@settings/tabs/connections/github/RepositoryList.tsx delete mode 100644 app/components/@settings/tabs/connections/github/StatsDisplay.tsx delete mode 100644 app/components/@settings/tabs/connections/github/index.ts delete mode 100644 app/components/@settings/tabs/connections/gitlab/GitLabConnection.tsx create mode 100644 app/components/@settings/tabs/github/GitHubTab.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubAuthDialog.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubCacheManager.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubConnection.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubErrorBoundary.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubProgressiveLoader.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubRepositoryCard.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubRepositorySelector.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubStats.tsx create mode 100644 app/components/@settings/tabs/github/components/GitHubUserProfile.tsx create mode 100644 app/components/@settings/tabs/github/components/shared/GitHubStateIndicators.tsx create mode 100644 app/components/@settings/tabs/github/components/shared/RepositoryCard.tsx create mode 100644 app/components/@settings/tabs/github/components/shared/index.ts create mode 100644 app/components/@settings/tabs/gitlab/GitLabTab.tsx create mode 100644 app/components/@settings/tabs/gitlab/components/GitLabAuthDialog.tsx create mode 100644 app/components/@settings/tabs/gitlab/components/GitLabConnection.tsx create mode 100644 app/components/@settings/tabs/gitlab/components/GitLabRepositorySelector.tsx rename app/components/@settings/tabs/{connections/gitlab => gitlab/components}/RepositoryCard.tsx (100%) rename app/components/@settings/tabs/{connections/gitlab => gitlab/components}/RepositoryList.tsx (100%) rename app/components/@settings/tabs/{connections/gitlab => gitlab/components}/StatsDisplay.tsx (100%) rename app/components/@settings/tabs/{connections/gitlab => gitlab/components}/index.ts (100%) create mode 100644 app/components/@settings/tabs/netlify/NetlifyTab.tsx rename app/components/@settings/tabs/{connections/netlify => netlify/components}/NetlifyConnection.tsx (81%) rename app/components/@settings/tabs/{connections/netlify => netlify/components}/index.ts (100%) delete mode 100644 app/components/@settings/tabs/providers/local/LocalProvidersTab.new.tsx create mode 100644 app/components/@settings/tabs/supabase/SupabaseTab.tsx create mode 100644 app/components/@settings/tabs/vercel/VercelTab.tsx rename app/components/@settings/tabs/{connections/vercel => vercel/components}/VercelConnection.tsx (100%) rename app/components/@settings/tabs/{connections/vercel => vercel/components}/index.ts (100%) create mode 100644 app/components/ui/BranchSelector.tsx create mode 100644 app/lib/hooks/useConnectionTest.ts create mode 100644 app/lib/hooks/useGitHubAPI.ts create mode 100644 app/lib/hooks/useGitHubConnection.ts create mode 100644 app/lib/hooks/useGitHubStats.ts create mode 100644 app/lib/hooks/useGitLabAPI.ts create mode 100644 app/lib/hooks/useGitLabConnection.ts create mode 100644 app/lib/security.ts create mode 100644 app/lib/stores/github.ts create mode 100644 app/lib/utils/serviceErrorHandler.ts create mode 100644 app/routes/api.github-branches.ts create mode 100644 app/routes/api.github-stats.ts create mode 100644 app/routes/api.github-user.ts create mode 100644 app/routes/api.gitlab-branches.ts create mode 100644 app/routes/api.gitlab-projects.ts create mode 100644 app/routes/api.netlify-user.ts create mode 100644 app/routes/api.supabase-user.ts create mode 100644 app/routes/api.vercel-user.ts delete mode 100644 app/utils/cn.ts delete mode 100644 app/utils/types.ts delete mode 100644 tests/preview/smoke.spec.ts diff --git a/.env.example b/.env.example index dc7a664..9bec51e 100644 --- a/.env.example +++ b/.env.example @@ -136,7 +136,7 @@ VITE_GITHUB_TOKEN_TYPE=classic # - api (for full API access including project creation and commits) # - read_repository (to clone/import repositories) # - write_repository (to push commits and update branches) -VITE_GITLAB_ACCESS_TOKEN= +VITE_GITLAB_ACCESS_TOKEN=your_gitlab_personal_access_token_here # Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) VITE_GITLAB_URL=https://gitlab.com @@ -145,9 +145,43 @@ VITE_GITLAB_URL=https://gitlab.com VITE_GITLAB_TOKEN_TYPE=personal-access-token # ====================================== -# DEVELOPMENT SETTINGS +# VERCEL INTEGRATION # ====================================== +# Vercel Access Token +# Get your access token from: https://vercel.com/account/tokens +# This token is used for: +# 1. Deploying projects to Vercel +# 2. Managing Vercel projects and deployments +# 3. Accessing project analytics and logs +VITE_VERCEL_ACCESS_TOKEN=your_vercel_access_token_here + +# ====================================== +# NETLIFY INTEGRATION +# ====================================== + +# Netlify Access Token +# Get your access token from: https://app.netlify.com/user/applications +# This token is used for: +# 1. Deploying sites to Netlify +# 2. Managing Netlify sites and deployments +# 3. Accessing build logs and analytics +VITE_NETLIFY_ACCESS_TOKEN=your_netlify_access_token_here + +# ====================================== +# SUPABASE INTEGRATION +# ====================================== + +# Supabase Project Configuration +# Get your project details from: https://supabase.com/dashboard +# Select your project → Settings → API +VITE_SUPABASE_URL=your_supabase_project_url_here +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key_here + +# Supabase Access Token (for management operations) +# Generate from: https://supabase.com/dashboard/account/tokens +VITE_SUPABASE_ACCESS_TOKEN=your_supabase_access_token_here + # ====================================== # DEVELOPMENT SETTINGS # ====================================== @@ -155,8 +189,8 @@ VITE_GITLAB_TOKEN_TYPE=personal-access-token # Development Mode NODE_ENV=development -# Application Port (optional, defaults to 3000) -PORT=3000 +# Application Port (optional, defaults to 5173 for development) +PORT=5173 # Logging Level (debug, info, warn, error) VITE_LOG_LEVEL=debug @@ -165,90 +199,11 @@ VITE_LOG_LEVEL=debug DEFAULT_NUM_CTX=32768 # ====================================== -# INSTRUCTIONS +# SETUP INSTRUCTIONS # ====================================== # 1. Copy this file to .env.local: cp .env.example .env.local -# 2. Fill in the API keys you want to use -# 3. Restart your development server: npm run dev -# 4. Go to Settings > Providers to enable/configure providers -# ====================================== -# GITLAB INTEGRATION -# ====================================== - -# GitLab Personal Access Token -# Get your GitLab Personal Access Token here: -# https://gitlab.com/-/profile/personal_access_tokens -# -# This token is used for: -# 1. Importing/cloning GitLab repositories -# 2. Accessing private projects -# 3. Creating/updating branches -# 4. Creating/updating commits and pushing code -# 5. Creating new GitLab projects via the API -# -# Make sure your token has the following scopes: -# - api (for full API access including project creation and commits) -# - read_repository (to clone/import repositories) -# - write_repository (to push commits and update branches) -VITE_GITLAB_ACCESS_TOKEN= - -# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) -VITE_GITLAB_URL=https://gitlab.com - -# GitLab token type should be 'personal-access-token' -VITE_GITLAB_TOKEN_TYPE=personal-access-token - -# ====================================== -# GITLAB INTEGRATION -# ====================================== - -# GitLab Personal Access Token -# Get your GitLab Personal Access Token here: -# https://gitlab.com/-/profile/personal_access_tokens -# -# This token is used for: -# 1. Importing/cloning GitLab repositories -# 2. Accessing private projects -# 3. Creating/updating branches -# 4. Creating/updating commits and pushing code -# 5. Creating new GitLab projects via the API -# -# Make sure your token has the following scopes: -# - api (for full API access including project creation and commits) -# - read_repository (to clone/import repositories) -# - write_repository (to push commits and update branches) -VITE_GITLAB_ACCESS_TOKEN= - -# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) -VITE_GITLAB_URL=https://gitlab.com - -# GitLab token type should be 'personal-access-token' -VITE_GITLAB_TOKEN_TYPE=personal-access-token - - -# ====================================== -# GITLAB INTEGRATION -# ====================================== - -# GitLab Personal Access Token -# Get your GitLab Personal Access Token here: -# https://gitlab.com/-/profile/personal_access_tokens -# -# This token is used for: -# 1. Importing/cloning GitLab repositories -# 2. Accessing private projects -# 3. Creating/updating branches -# 4. Creating/updating commits and pushing code -# 5. Creating new GitLab projects via the API -# -# Make sure your token has the following scopes: -# - api (for full API access including project creation and commits) -# - read_repository (to clone/import repositories) -# - write_repository (to push commits and update branches) -VITE_GITLAB_ACCESS_TOKEN= - -# Set the GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) -VITE_GITLAB_URL=https://gitlab.com - -# GitLab token type should be 'personal-access-token' -VITE_GITLAB_TOKEN_TYPE=personal-access-token +# 2. Fill in the API keys for the services you want to use +# 3. All service integration keys use VITE_ prefix for auto-connection +# 4. Restart your development server: pnpm run dev +# 5. Services will auto-connect on startup if tokens are provided +# 6. Go to Settings > Service tabs to manage connections manually if needed diff --git a/.env.production b/.env.production index 8fe4367..84d2d75 100644 --- a/.env.production +++ b/.env.production @@ -103,9 +103,36 @@ VITE_GITHUB_ACCESS_TOKEN= # Classic tokens are recommended for broader access VITE_GITHUB_TOKEN_TYPE= -# Netlify Authentication +# ====================================== +# SERVICE INTEGRATIONS +# ====================================== + +# GitLab Personal Access Token +# Get your GitLab Personal Access Token here: +# https://gitlab.com/-/profile/personal_access_tokens +# Required scopes: api, read_repository, write_repository +VITE_GITLAB_ACCESS_TOKEN= + +# GitLab instance URL (e.g., https://gitlab.com or your self-hosted domain) +VITE_GITLAB_URL=https://gitlab.com + +# GitLab token type +VITE_GITLAB_TOKEN_TYPE=personal-access-token + +# Vercel Access Token +# Get your access token from: https://vercel.com/account/tokens +VITE_VERCEL_ACCESS_TOKEN= + +# Netlify Access Token +# Get your access token from: https://app.netlify.com/user/applications VITE_NETLIFY_ACCESS_TOKEN= +# Supabase Configuration +# Get your project details from: https://supabase.com/dashboard +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= +VITE_SUPABASE_ACCESS_TOKEN= + # Example Context Values for qwen2.5-coder:32b # # DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3f4eb97..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:prettier/recommended" - ], - "rules": { - // example: turn off console warnings - "no-console": "off" - } - } - \ No newline at end of file diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 76a8bd4..cf97fe5 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -21,7 +21,11 @@ import NotificationsTab from '~/components/@settings/tabs/notifications/Notifica import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; import { DataTab } from '~/components/@settings/tabs/data/DataTab'; import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; -import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; +import GitHubTab from '~/components/@settings/tabs/github/GitHubTab'; +import GitLabTab from '~/components/@settings/tabs/gitlab/GitLabTab'; +import SupabaseTab from '~/components/@settings/tabs/supabase/SupabaseTab'; +import VercelTab from '~/components/@settings/tabs/vercel/VercelTab'; +import NetlifyTab from '~/components/@settings/tabs/netlify/NetlifyTab'; import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; import McpTab from '~/components/@settings/tabs/mcp/McpTab'; @@ -133,8 +137,16 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return ; case 'local-providers': return ; - case 'connection': - return ; + case 'github': + return ; + case 'gitlab': + return ; + case 'supabase': + return ; + case 'vercel': + return ; + case 'netlify': + return ; case 'event-logs': return ; case 'mcp': @@ -151,7 +163,11 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return hasNewFeatures; case 'notifications': return hasUnreadNotifications; - case 'connection': + case 'github': + case 'gitlab': + case 'supabase': + case 'vercel': + case 'netlify': return hasConnectionIssues; default: return false; @@ -164,7 +180,11 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`; case 'notifications': return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; - case 'connection': + case 'github': + case 'gitlab': + case 'supabase': + case 'vercel': + case 'netlify': return currentIssue === 'disconnected' ? 'Connection lost' : currentIssue === 'high-latency' @@ -188,7 +208,11 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { case 'notifications': markAllAsRead(); break; - case 'connection': + case 'github': + case 'gitlab': + case 'supabase': + case 'vercel': + case 'netlify': acknowledgeIssue(); break; } diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts deleted file mode 100644 index db17f1e..0000000 --- a/app/components/@settings/core/constants.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { TabType } from './types'; - -export const TAB_ICONS: Record = { - profile: 'i-ph:user-circle', - settings: 'i-ph:gear-six', - notifications: 'i-ph:bell', - features: 'i-ph:star', - data: 'i-ph:database', - 'cloud-providers': 'i-ph:cloud', - 'local-providers': 'i-ph:laptop', - connection: 'i-ph:wifi-high', - 'event-logs': 'i-ph:list-bullets', - mcp: 'i-ph:wrench', -}; - -export const TAB_LABELS: Record = { - profile: 'Profile', - settings: 'Settings', - notifications: 'Notifications', - features: 'Features', - data: 'Data Management', - 'cloud-providers': 'Cloud Providers', - 'local-providers': 'Local Providers', - connection: 'Connection', - 'event-logs': 'Event Logs', - mcp: 'MCP Servers', -}; - -export const TAB_DESCRIPTIONS: Record = { - profile: 'Manage your profile and account settings', - settings: 'Configure application preferences', - notifications: 'View and manage your notifications', - features: 'Explore new and upcoming features', - data: 'Manage your data and storage', - 'cloud-providers': 'Configure cloud AI providers and models', - 'local-providers': 'Configure local AI providers and models', - connection: 'Check connection status and settings', - 'event-logs': 'View system events and logs', - mcp: 'Configure MCP (Model Context Protocol) servers', -}; - -export const DEFAULT_TAB_CONFIG = [ - // User Window Tabs (Always visible by default) - { id: 'features', visible: true, window: 'user' as const, order: 0 }, - { id: 'data', visible: true, window: 'user' as const, order: 1 }, - { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 }, - { id: 'local-providers', visible: true, window: 'user' as const, order: 3 }, - { id: 'connection', visible: true, window: 'user' as const, order: 4 }, - { id: 'notifications', visible: true, window: 'user' as const, order: 5 }, - { id: 'event-logs', visible: true, window: 'user' as const, order: 6 }, - { id: 'mcp', visible: true, window: 'user' as const, order: 7 }, - - { id: 'profile', visible: true, window: 'user' as const, order: 9 }, - { id: 'settings', visible: true, window: 'user' as const, order: 10 }, - - // User Window Tabs (In dropdown, initially hidden) -]; diff --git a/app/components/@settings/core/constants.tsx b/app/components/@settings/core/constants.tsx new file mode 100644 index 0000000..88085a9 --- /dev/null +++ b/app/components/@settings/core/constants.tsx @@ -0,0 +1,108 @@ +import type { TabType } from './types'; +import { User, Settings, Bell, Star, Database, Cloud, Laptop, Github, Wrench, List } from 'lucide-react'; + +// GitLab icon component +const GitLabIcon = () => ( + + + +); + +// Vercel icon component +const VercelIcon = () => ( + + + +); + +// Netlify icon component +const NetlifyIcon = () => ( + + + +); + +// Supabase icon component +const SupabaseIcon = () => ( + + + +); + +export const TAB_ICONS: Record> = { + profile: User, + settings: Settings, + notifications: Bell, + features: Star, + data: Database, + 'cloud-providers': Cloud, + 'local-providers': Laptop, + github: Github, + gitlab: () => , + netlify: () => , + vercel: () => , + supabase: () => , + 'event-logs': List, + mcp: Wrench, +}; + +export const TAB_LABELS: Record = { + profile: 'Profile', + settings: 'Settings', + notifications: 'Notifications', + features: 'Features', + data: 'Data Management', + 'cloud-providers': 'Cloud Providers', + 'local-providers': 'Local Providers', + github: 'GitHub', + gitlab: 'GitLab', + netlify: 'Netlify', + vercel: 'Vercel', + supabase: 'Supabase', + 'event-logs': 'Event Logs', + mcp: 'MCP Servers', +}; + +export const TAB_DESCRIPTIONS: Record = { + profile: 'Manage your profile and account settings', + settings: 'Configure application preferences', + notifications: 'View and manage your notifications', + features: 'Explore new and upcoming features', + data: 'Manage your data and storage', + 'cloud-providers': 'Configure cloud AI providers and models', + 'local-providers': 'Configure local AI providers and models', + github: 'Connect and manage GitHub integration', + gitlab: 'Connect and manage GitLab integration', + netlify: 'Configure Netlify deployment settings', + vercel: 'Manage Vercel projects and deployments', + supabase: 'Setup Supabase database connection', + 'event-logs': 'View system events and logs', + mcp: 'Configure MCP (Model Context Protocol) servers', +}; + +export const DEFAULT_TAB_CONFIG = [ + // User Window Tabs (Always visible by default) + { id: 'features', visible: true, window: 'user' as const, order: 0 }, + { id: 'data', visible: true, window: 'user' as const, order: 1 }, + { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 }, + { id: 'local-providers', visible: true, window: 'user' as const, order: 3 }, + { id: 'github', visible: true, window: 'user' as const, order: 4 }, + { id: 'gitlab', visible: true, window: 'user' as const, order: 5 }, + { id: 'netlify', visible: true, window: 'user' as const, order: 6 }, + { id: 'vercel', visible: true, window: 'user' as const, order: 7 }, + { id: 'supabase', visible: true, window: 'user' as const, order: 8 }, + { id: 'notifications', visible: true, window: 'user' as const, order: 9 }, + { id: 'event-logs', visible: true, window: 'user' as const, order: 10 }, + { id: 'mcp', visible: true, window: 'user' as const, order: 11 }, + + // User Window Tabs (In dropdown, initially hidden) +]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index a679e9d..0b5dd57 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { User, Folder, Wifi, Settings, Box, Sliders } from 'lucide-react'; export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; @@ -10,7 +11,11 @@ export type TabType = | 'data' | 'cloud-providers' | 'local-providers' - | 'connection' + | 'github' + | 'gitlab' + | 'netlify' + | 'vercel' + | 'supabase' | 'event-logs' | 'mcp'; @@ -69,7 +74,11 @@ export const TAB_LABELS: Record = { data: 'Data Management', 'cloud-providers': 'Cloud Providers', 'local-providers': 'Local Providers', - connection: 'Connections', + github: 'GitHub', + gitlab: 'GitLab', + netlify: 'Netlify', + vercel: 'Vercel', + supabase: 'Supabase', 'event-logs': 'Event Logs', mcp: 'MCP Servers', }; @@ -83,13 +92,13 @@ export const categoryLabels: Record = { preferences: 'Preferences', }; -export const categoryIcons: Record = { - profile: 'i-ph:user-circle', - file_sharing: 'i-ph:folder-simple', - connectivity: 'i-ph:wifi-high', - system: 'i-ph:gear', - services: 'i-ph:cube', - preferences: 'i-ph:sliders', +export const categoryIcons: Record> = { + profile: User, + file_sharing: Folder, + connectivity: Wifi, + system: Settings, + services: Box, + preferences: Sliders, }; export interface Profile { diff --git a/app/components/@settings/shared/components/DraggableTabList.tsx b/app/components/@settings/shared/components/DraggableTabList.tsx deleted file mode 100644 index a868183..0000000 --- a/app/components/@settings/shared/components/DraggableTabList.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useDrag, useDrop } from 'react-dnd'; -import { motion } from 'framer-motion'; -import { classNames } from '~/utils/classNames'; -import type { TabVisibilityConfig } from '~/components/@settings/core/types'; -import { TAB_LABELS } from '~/components/@settings/core/types'; -import { Switch } from '~/components/ui/Switch'; - -interface DraggableTabListProps { - tabs: TabVisibilityConfig[]; - onReorder: (tabs: TabVisibilityConfig[]) => void; - onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; - onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; - showControls?: boolean; -} - -interface DraggableTabItemProps { - tab: TabVisibilityConfig; - index: number; - moveTab: (dragIndex: number, hoverIndex: number) => void; - showControls?: boolean; - onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; - onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; -} - -interface DragItem { - type: string; - index: number; - id: string; -} - -const DraggableTabItem = ({ - tab, - index, - moveTab, - showControls, - onWindowChange, - onVisibilityChange, -}: DraggableTabItemProps) => { - const [{ isDragging }, dragRef] = useDrag({ - type: 'tab', - item: { type: 'tab', index, id: tab.id }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - const [, dropRef] = useDrop({ - accept: 'tab', - hover: (item: DragItem, monitor) => { - if (!monitor.isOver({ shallow: true })) { - return; - } - - if (item.index === index) { - return; - } - - if (item.id === tab.id) { - return; - } - - moveTab(item.index, index); - item.index = index; - }, - }); - - const ref = (node: HTMLDivElement | null) => { - dragRef(node); - dropRef(node); - }; - - return ( - -
-
-
-
-
-
{TAB_LABELS[tab.id]}
- {showControls && ( -
- Order: {tab.order}, Window: {tab.window} -
- )} -
-
- {showControls && !tab.locked && ( -
-
- onVisibilityChange?.(tab, checked)} - className="data-[state=checked]:bg-purple-500" - aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`} - /> - -
-
- - onWindowChange?.(tab, checked ? 'developer' : 'user')} - className="data-[state=checked]:bg-purple-500" - aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`} - /> - -
-
- )} - - ); -}; - -export const DraggableTabList = ({ - tabs, - onReorder, - onWindowChange, - onVisibilityChange, - showControls = false, -}: DraggableTabListProps) => { - const moveTab = (dragIndex: number, hoverIndex: number) => { - const items = Array.from(tabs); - const [reorderedItem] = items.splice(dragIndex, 1); - items.splice(hoverIndex, 0, reorderedItem); - - // Update order numbers based on position - const reorderedTabs = items.map((tab, index) => ({ - ...tab, - order: index + 1, - })); - - onReorder(reorderedTabs); - }; - - return ( -
- {tabs.map((tab, index) => ( - - ))} -
- ); -}; diff --git a/app/components/@settings/shared/components/TabTile.tsx b/app/components/@settings/shared/components/TabTile.tsx index ebc0b19..a8a9383 100644 --- a/app/components/@settings/shared/components/TabTile.tsx +++ b/app/components/@settings/shared/components/TabTile.tsx @@ -70,16 +70,20 @@ export const TabTile: React.FC = ({ isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '', )} > -
+ {(() => { + const IconComponent = TAB_ICONS[tab.id]; + return ( + + ); + })()}
{/* Label and Description */} diff --git a/app/components/@settings/shared/service-integration/ConnectionForm.tsx b/app/components/@settings/shared/service-integration/ConnectionForm.tsx new file mode 100644 index 0000000..029de88 --- /dev/null +++ b/app/components/@settings/shared/service-integration/ConnectionForm.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +interface TokenTypeOption { + value: string; + label: string; + description?: string; +} + +interface ConnectionFormProps { + isConnected: boolean; + isConnecting: boolean; + token: string; + onTokenChange: (token: string) => void; + onConnect: (e: React.FormEvent) => void; + onDisconnect: () => void; + error?: string; + serviceName: string; + tokenLabel?: string; + tokenPlaceholder?: string; + getTokenUrl: string; + environmentVariable?: string; + tokenTypes?: TokenTypeOption[]; + selectedTokenType?: string; + onTokenTypeChange?: (type: string) => void; + connectedMessage?: string; + children?: React.ReactNode; // For additional form fields +} + +export function ConnectionForm({ + isConnected, + isConnecting, + token, + onTokenChange, + onConnect, + onDisconnect, + error, + serviceName, + tokenLabel = 'Access Token', + tokenPlaceholder, + getTokenUrl, + environmentVariable, + tokenTypes, + selectedTokenType, + onTokenTypeChange, + connectedMessage = `Connected to ${serviceName}`, + children, +}: ConnectionFormProps) { + return ( + +
+ {!isConnected ? ( +
+ {environmentVariable && ( +
+

+ + Tip: You can also set the{' '} + + {environmentVariable} + {' '} + environment variable to connect automatically. +

+
+ )} + +
+ {tokenTypes && tokenTypes.length > 1 && onTokenTypeChange && ( +
+ + + {selectedTokenType && tokenTypes.find((t) => t.value === selectedTokenType)?.description && ( +

+ {tokenTypes.find((t) => t.value === selectedTokenType)?.description} +

+ )} +
+ )} + +
+ + onTokenChange(e.target.value)} + disabled={isConnecting} + placeholder={tokenPlaceholder || `Enter your ${serviceName} access token`} + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-1', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> + + + {children} + + {error && ( +
+

{error}

+
+ )} + + + +
+ ) : ( +
+
+ + +
+ {connectedMessage} + +
+
+ )} +
+ + ); +} diff --git a/app/components/@settings/shared/service-integration/ConnectionTestIndicator.tsx b/app/components/@settings/shared/service-integration/ConnectionTestIndicator.tsx new file mode 100644 index 0000000..0e65a80 --- /dev/null +++ b/app/components/@settings/shared/service-integration/ConnectionTestIndicator.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +export interface ConnectionTestResult { + status: 'success' | 'error' | 'testing'; + message: string; + timestamp?: number; +} + +interface ConnectionTestIndicatorProps { + testResult: ConnectionTestResult | null; + className?: string; +} + +export function ConnectionTestIndicator({ testResult, className }: ConnectionTestIndicatorProps) { + if (!testResult) { + return null; + } + + return ( + +
+ {testResult.status === 'success' && ( +
+ )} + {testResult.status === 'error' && ( +
+ )} + {testResult.status === 'testing' && ( +
+ )} + + {testResult.message} + +
+ {testResult.timestamp && ( +

{new Date(testResult.timestamp).toLocaleString()}

+ )} + + ); +} diff --git a/app/components/@settings/shared/service-integration/ErrorState.tsx b/app/components/@settings/shared/service-integration/ErrorState.tsx new file mode 100644 index 0000000..a6f618f --- /dev/null +++ b/app/components/@settings/shared/service-integration/ErrorState.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; +import type { ServiceError } from '~/lib/utils/serviceErrorHandler'; + +interface ErrorStateProps { + error?: ServiceError | string; + title?: string; + onRetry?: () => void; + onDismiss?: () => void; + retryLabel?: string; + className?: string; + showDetails?: boolean; +} + +export function ErrorState({ + error, + title = 'Something went wrong', + onRetry, + onDismiss, + retryLabel = 'Try again', + className, + showDetails = false, +}: ErrorStateProps) { + const errorMessage = typeof error === 'string' ? error : error?.message || 'An unknown error occurred'; + const isServiceError = typeof error === 'object' && error !== null; + + return ( + +
+
+
+

{title}

+

{errorMessage}

+ + {showDetails && isServiceError && error.details && ( +
+ + Technical details + +
+                {JSON.stringify(error.details, null, 2)}
+              
+
+ )} + +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+
+ + ); +} + +interface ConnectionErrorProps { + service: string; + error: ServiceError | string; + onRetryConnection: () => void; + onClearError?: () => void; +} + +export function ConnectionError({ service, error, onRetryConnection, onClearError }: ConnectionErrorProps) { + return ( + + ); +} diff --git a/app/components/@settings/shared/service-integration/LoadingState.tsx b/app/components/@settings/shared/service-integration/LoadingState.tsx new file mode 100644 index 0000000..c9e486c --- /dev/null +++ b/app/components/@settings/shared/service-integration/LoadingState.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; + +interface LoadingStateProps { + message?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; + showProgress?: boolean; + progress?: number; +} + +export function LoadingState({ + message = 'Loading...', + size = 'md', + className, + showProgress = false, + progress = 0, +}: LoadingStateProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + return ( + +
+
+ {message} +
+ + {showProgress && ( +
+
+ +
+
+ )} + + ); +} + +interface SkeletonProps { + className?: string; + lines?: number; +} + +export function Skeleton({ className, lines = 1 }: SkeletonProps) { + return ( +
+ {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:

-
    -
  1. Check your browser console for errors
  2. -
  3. Verify that your tokens have the correct permissions
  4. -
  5. Try clearing your browser cache and cookies
  6. -
  7. Ensure your browser allows third-party cookies if using integrations
  8. -
-
-
- ); -} 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 - - -
-
- -
- - -
-
- -
- - setToken(e.target.value)} - placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - className="w-full px-3 py-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive" - disabled={isSubmitting} - autoComplete="off" - /> -
- -
-
-
-
-

To create a GitHub Personal Access Token:

-
    -
  1. Go to GitHub Settings → Developer settings → Personal access tokens
  2. -
  3. Click "Generate new token"
  4. -
  5. Select appropriate scopes (repo, user, etc.)
  6. -
  7. Copy and paste the token here
  8. -
-

- - Learn more about creating tokens → - -

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

- {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 && ( - - -
-
-
- GitHub 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 ( -
-
-
- Loading... -
-
- ); - } - - return ( - -
-
-
-
- - - -
-

GitLab Connection

-
-
- - {!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', - )} - /> -
- - Get your token -
- - - Required scopes: api, read_repository -
-
-
- -
- {!isConnected ? ( - - ) : ( - <> -
-
- - -
- Connected to GitLab - -
-
- - -
-
- - )} -
- - {isConnected.get() && userAtom.get() && stats.get() && ( -
-
-
- {userAtom.get()?.avatar_url && - userAtom.get()?.avatar_url !== 'null' && - userAtom.get()?.avatar_url !== '' ? ( - {userAtom.get()?.username} { - // 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}

-
-
- - - -
-
-
- GitLab Stats -
-
-
- - -
- { - 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

+
+ +
+
+ + +
+ +
+ + setToken(e.target.value)} + disabled={isConnecting} + placeholder={`Enter your GitHub ${ + tokenType === 'classic' ? 'personal access token' : 'fine-grained token' + }`} + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-1', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> + + + {error && ( +
+

{error}

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

+
+ )} + +
+
+
+ + +
+ +
+ + setToken(e.target.value)} + disabled={isConnecting || isConnected} + placeholder={`Enter your GitHub ${ + tokenType === 'classic' ? 'personal access token' : 'fine-grained token' + }`} + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> +
+ + Get your token +
+ + + + Required scopes:{' '} + {tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'} + +
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {!isConnected ? ( + + ) : ( +
+
+ + +
+ Connected to GitHub + +
+
+ + +
+
+ )} +
+ +
+ + ); +} 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 && ( +
+
Organizations
+
+ {stats.organizations.map((org) => ( + + {org.login} +
+
+ {org.name || org.login} +
+

{org.login}

+ {org.description && ( +

{org.description}

+ )} +
+
+ )} + + {/* 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.login} +
+

+ {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 ( +
+ +

{message}

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

+
+
+
+
+ Loading... +
+
+
+ ); + } + + // 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 !== '' ? ( + {connection.user.username} { + 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 +

+
+
+ +
+
+ + setGitlabUrl(e.target.value)} + disabled={isConnecting} + placeholder="https://gitlab.com" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3', + 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark', + 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark', + 'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark', + 'focus:outline-none focus:ring-2 focus:ring-orange-500', + 'disabled:opacity-50 disabled:cursor-not-allowed', + )} + /> +
+ +
+ + setToken(e.target.value)} + disabled={isConnecting} + placeholder="Enter your GitLab access token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3', + 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark', + 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark', + 'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark', + 'focus:outline-none focus:ring-2 focus:ring-orange-500', + 'disabled:opacity-50 disabled:cursor-not-allowed', + )} + required + /> +
+ + Get your token +
+ + + Required scopes: api, read_repository +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + Cancel + + + {isConnecting ? ( + <> +
+ Connecting... + + ) : ( + <> +
+ Connect to GitLab + + )} + +
+ + + +
+ + + ); +} 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 ( + +
+
+
+
+ + + +
+

GitLab Connection

+
+
+ + {!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} + placeholder="https://gitlab.com" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-1', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> +
+ +
+ + setToken(e.target.value)} + disabled={isConnecting || isConnected} + placeholder="Enter your GitLab access token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-1', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> +
+ + Get your token +
+ + + Required scopes: api, read_repository +
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {!isConnected ? ( + <> + + + + ) : ( + <> +
+
+ + +
+ Connected to GitLab + +
+
+ + +
+
+ + )} +
+ +
+ + ); +} 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 Stats + +
+
+
+ + +
+ {/* 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) => ( +
+
+
+ {metric.label} +
+ {metric.value} +
+ ))} +
+
+ +
+ +
+ {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.name} + +
+
+ + {site.published_deploy?.state === 'ready' ? ( +
+ ) : ( +
+ )} + + {site.published_deploy?.state || 'Unknown'} + + +
+
+ +
+
+ e.stopPropagation()} + > + +
+ {site.published_deploy?.framework && ( +
+
+ {site.published_deploy.framework} +
+ )} + {site.custom_domain && ( +
+
+ Custom Domain +
+ )} + {site.branch && ( +
+
+ {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 && ( +
+ e.stopPropagation()} + > + + )} +
+ + {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 && ( - - -
-
- -

Installed Models

-
- -
-
- - {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 ?? '--'} +
+
+
+ Tables +
+
+
+
+ {project.stats?.storage?.buckets ?? '--'} +
+
+
+ Buckets +
+
+
+
+ {project.stats?.functions?.deployed ?? '--'} +
+
+
+ Functions +
+
+
+
+ {project.stats?.database?.size_mb ? `${project.stats.database.size_mb} MB` : '--'} +
+
+
+ DB Size +
+
+
+
+
+ + {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) => ( +
+
+
+ {metric.label} +
+ {metric.value} +
+ ))} +
+
+ + {/* Usage Metrics */} +
+
+
+
+ Database +
+
+
+ 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` : '--'; + })()} +
+
+
+
+
+
+ Storage +
+
+
+ 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` : '--'; + })()} +
+
+
+
+
+
+ Functions +
+
+
+ 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) => ( +
+
+
+ {metric.label} +
+ {metric.value} +
+ ))} +
+
+ ) : null} + + {connection.stats?.projects?.length ? ( +
+ {connection.stats.projects.map((project) => ( +
+
+
+
+
+ {project.name} +
+
+ {project.targets?.production?.alias && project.targets.production.alias.length > 0 ? ( + <> + a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`} + target="_blank" + rel="noopener noreferrer" + className="hover:text-bolt-elements-borderColorActive underline" + > + {project.targets.production.alias.find( + (a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'), + ) || project.targets.production.alias[0]} + + + +
+ {new Date(project.createdAt).toLocaleDateString()} + + + ) : project.latestDeployments && project.latestDeployments.length > 0 ? ( + <> + + {project.latestDeployments[0].url} + + + +
+ {new Date(project.latestDeployments[0].created).toLocaleDateString()} + + + ) : null} +
+ + {/* Project Details Grid */} +
+
+
+ {/* Deployments - This would be fetched from API */} + -- +
+
+
+ Deployments +
+
+
+
+ {/* Domains - This would be fetched from API */} + -- +
+
+
+ Domains +
+
+
+
+ {/* Team Members - This would be fetched from API */} + -- +
+
+
+ Team +
+
+
+
+ {/* Bandwidth - This would be fetched from API */} + -- +
+
+
+ Bandwidth +
+
+
+
+
+ {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 + +
+
+ +
+
+ User Avatar +
+

+ {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 */} +
+
+
+
+ Projects +
+
+
+ Active:{' '} + {connection.stats?.projects.filter((p) => p.latestDeployments?.[0]?.state === 'READY').length || + 0} +
+
Total: {connection.stats?.totalProjects || 0}
+
+
+
+
+
+ Domains +
+
+ {/* Domain usage would be fetched from API */} +
Custom: --
+
Vercel: --
+
+
+
+
+
+ Usage +
+
+ {/* 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)}
- +
@@ -316,7 +315,7 @@ ${escapeBoltTags(file.content)}
- +
diff --git a/app/components/deploy/GitHubDeploymentDialog.tsx b/app/components/deploy/GitHubDeploymentDialog.tsx index 84885da..75cc114 100644 --- a/app/components/deploy/GitHubDeploymentDialog.tsx +++ b/app/components/deploy/GitHubDeploymentDialog.tsx @@ -9,7 +9,7 @@ import type { GitHubUserResponse, GitHubRepoInfo } from '~/types/GitHub'; import { logStore } from '~/lib/stores/logs'; import { chatId } from '~/lib/persistence/useChatHistory'; import { useStore } from '@nanostores/react'; -import { AuthDialog as GitHubAuthDialog } from '~/components/@settings/tabs/connections/github/AuthDialog'; +import { GitHubAuthDialog } from '~/components/@settings/tabs/github/components/GitHubAuthDialog'; import { SearchInput, EmptyState, StatusIndicator, Badge } from '~/components/ui'; interface GitHubDeploymentDialogProps { @@ -34,13 +34,33 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: const [showAuthDialog, setShowAuthDialog] = useState(false); const currentChatId = useStore(chatId); - // Load GitHub connection on mount + /* + * Load GitHub connection on mount + * Helper function to sanitize repository name + */ + const sanitizeRepoName = (name: string): string => { + return ( + name + .toLowerCase() + // Replace spaces and underscores with hyphens + .replace(/[\s_]+/g, '-') + // Remove special characters except hyphens and alphanumeric + .replace(/[^a-z0-9-]/g, '') + // Remove multiple consecutive hyphens + .replace(/-+/g, '-') + // Remove leading/trailing hyphens + .replace(/^-+|-+$/g, '') + // Ensure it's not empty and has reasonable length + .substring(0, 100) || 'my-project' + ); + }; + useEffect(() => { if (isOpen) { const connection = getLocalStorage('github_connection'); - // Set a default repository name based on the project name - setRepoName(projectName.replace(/\s+/g, '-').toLowerCase()); + // Set a default repository name based on the project name with proper sanitization + setRepoName(sanitizeRepoName(projectName)); if (connection?.user && connection?.token) { setUser(connection.user); @@ -180,6 +200,25 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: return; } + // Validate repository name + const sanitizedName = sanitizeRepoName(repoName); + + if (!sanitizedName || sanitizedName.length < 1) { + toast.error('Repository name must contain at least one alphanumeric character'); + return; + } + + if (sanitizedName.length > 100) { + toast.error('Repository name is too long (maximum 100 characters)'); + return; + } + + // Update the repo name field with the sanitized version if it was changed + if (sanitizedName !== repoName) { + setRepoName(sanitizedName); + toast.info(`Repository name sanitized to: ${sanitizedName}`); + } + setIsLoading(true); try { @@ -188,10 +227,11 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: let repoExists = false; try { - // Check if the repository already exists + // Check if the repository already exists - ensure repo name is properly sanitized + const sanitizedRepoName = sanitizeRepoName(repoName); const { data: existingRepo } = await octokit.repos.get({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, }); repoExists = true; @@ -219,7 +259,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: if (existingRepo.private !== isPrivate) { await octokit.repos.update({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, private: isPrivate, }); } @@ -232,8 +272,9 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: // Create repository if it doesn't exist if (!repoExists) { + const sanitizedRepoName = sanitizeRepoName(repoName); const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ - name: repoName, + name: sanitizedRepoName, private: isPrivate, // Initialize with a README to avoid empty repository issues @@ -253,7 +294,8 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: await new Promise((resolve) => setTimeout(resolve, 2000)); } else { // Set URL for existing repo - setCreatedRepoUrl(`https://github.com/${connection.user.login}/${repoName}`); + const sanitizedRepoName = sanitizeRepoName(repoName); + setCreatedRepoUrl(`https://github.com/${connection.user.login}/${sanitizedRepoName}`); } // Process files to upload @@ -279,9 +321,10 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: try { // For both new and existing repos, get the repository info + const sanitizedRepoName = sanitizeRepoName(repoName); const { data: repo } = await octokit.repos.get({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, }); defaultBranch = repo.default_branch || 'main'; console.log(`Repository default branch: ${defaultBranch}`); @@ -290,7 +333,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: try { const { data: refData } = await octokit.git.getRef({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, }); @@ -300,7 +343,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: // Get the latest commit to use as a base for our tree const { data: commitData } = await octokit.git.getCommit({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, commit_sha: baseSha, }); @@ -331,9 +374,10 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: console.log(`Creating tree with ${tree.length} files using base: ${baseSha || 'none'}`); // Create a tree with all the files, using the base tree if available + const sanitizedRepoName = sanitizeRepoName(repoName); const { data: treeData } = await octokit.git.createTree({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, tree, base_tree: baseSha || undefined, }); @@ -346,7 +390,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: try { const { data: refData } = await octokit.git.getRef({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, }); parentCommitSha = refData.object.sha; @@ -361,7 +405,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: const { data: commitData } = await octokit.git.createCommit({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, message: !repoExists ? 'Initial commit from Bolt.diy' : 'Update from Bolt.diy', tree: treeData.sha, parents: parentCommitSha ? [parentCommitSha] : [], // Use parent if available @@ -374,7 +418,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: console.log(`Updating reference: heads/${defaultBranch} to ${commitData.sha}`); await octokit.git.updateRef({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, sha: commitData.sha, force: true, // Use force to ensure the update works @@ -387,7 +431,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: try { await octokit.git.createRef({ owner: connection.user.login, - repo: repoName, + repo: sanitizedRepoName, ref: `refs/heads/${defaultBranch}`, sha: commitData.sha, }); @@ -413,12 +457,13 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: } // Save the repository information for this chat + const sanitizedRepoName = sanitizeRepoName(repoName); localStorage.setItem( `github-repo-${currentChatId}`, JSON.stringify({ owner: connection.user.login, - name: repoName, - url: `https://github.com/${connection.user.login}/${repoName}`, + name: sanitizedRepoName, + url: `https://github.com/${connection.user.login}/${sanitizedRepoName}`, }), ); @@ -428,14 +473,42 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: console.error('Error pushing to GitHub:', error); // Attempt to extract more specific error information - let errorMessage = 'Failed to push to GitHub.'; + let errorMessage = 'Failed to push to GitHub'; + let isRetryable = false; if (error instanceof Error) { - errorMessage = error.message; + const errorMsg = error.message.toLowerCase(); + + if (errorMsg.includes('network') || errorMsg.includes('fetch failed') || errorMsg.includes('connection')) { + errorMessage = 'Network error. Please check your internet connection and try again.'; + isRetryable = true; + } else if (errorMsg.includes('401') || errorMsg.includes('unauthorized')) { + errorMessage = 'GitHub authentication failed. Please check your access token in Settings > Connections.'; + } else if (errorMsg.includes('403') || errorMsg.includes('forbidden')) { + errorMessage = + 'Access denied. Your GitHub token may not have sufficient permissions to create/modify repositories.'; + } else if (errorMsg.includes('404') || errorMsg.includes('not found')) { + errorMessage = 'Repository or resource not found. Please check the repository name and your permissions.'; + } else if (errorMsg.includes('422') || errorMsg.includes('validation failed')) { + if (errorMsg.includes('name already exists')) { + errorMessage = + 'A repository with this name already exists in your account. Please choose a different name.'; + } else { + errorMessage = 'Repository validation failed. Please check the repository name and settings.'; + } + } else if (errorMsg.includes('rate limit') || errorMsg.includes('429')) { + errorMessage = 'GitHub API rate limit exceeded. Please wait a moment and try again.'; + isRetryable = true; + } else if (errorMsg.includes('timeout')) { + errorMessage = 'Request timed out. Please check your connection and try again.'; + isRetryable = true; + } else { + errorMessage = `GitHub error: ${error.message}`; + } } else if (typeof error === 'object' && error !== null) { // Octokit errors if ('message' in error) { - errorMessage = error.message as string; + errorMessage = `GitHub API error: ${error.message as string}`; } // GitHub API errors @@ -444,7 +517,17 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: } } - toast.error(`GitHub deployment failed: ${errorMessage}`); + // Show error with retry suggestion if applicable + const finalMessage = isRetryable ? `${errorMessage} Click to retry.` : errorMessage; + toast.error(finalMessage); + + // Log detailed error for debugging + console.error('Detailed GitHub deployment error:', { + error, + repoName: sanitizeRepoName(repoName), + user: connection?.user?.login, + isRetryable, + }); } finally { setIsLoading(false); } @@ -488,6 +571,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl" aria-describedby="success-dialog-description" > + Successfully pushed to GitHub
@@ -624,6 +708,7 @@ export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl" aria-describedby="connection-required-description" > + GitHub Connection Required
+ {repoName && sanitizeRepoName(repoName) !== repoName && ( +

+ Will be created as:{' '} + + {sanitizeRepoName(repoName)} + +

+ )}
diff --git a/app/components/deploy/GitLabDeploymentDialog.tsx b/app/components/deploy/GitLabDeploymentDialog.tsx index 7f2b5cb..773a844 100644 --- a/app/components/deploy/GitLabDeploymentDialog.tsx +++ b/app/components/deploy/GitLabDeploymentDialog.tsx @@ -11,6 +11,7 @@ import { useStore } from '@nanostores/react'; import { GitLabApiService } from '~/lib/services/gitlabApiService'; import { SearchInput, EmptyState, StatusIndicator, Badge } from '~/components/ui'; import { formatSize } from '~/utils/formatSize'; +import { GitLabAuthDialog } from '~/components/@settings/tabs/gitlab/components/GitLabAuthDialog'; interface GitLabDeploymentDialogProps { isOpen: boolean; @@ -31,6 +32,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [createdRepoUrl, setCreatedRepoUrl] = useState(''); const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]); + const [showAuthDialog, setShowAuthDialog] = useState(false); const currentChatId = useStore(chatId); // Load GitLab connection on mount @@ -114,12 +116,24 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: setIsLoading(true); + // Sanitize repository name to match what the API will create + const sanitizedRepoName = repoName + .replace(/[^a-zA-Z0-9-_.]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); + try { const gitlabUrl = connection.gitlabUrl || 'https://gitlab.com'; const apiService = new GitLabApiService(connection.token, gitlabUrl); - // Check if project exists - const projectPath = `${connection.user.username}/${repoName}`; + // Warn user if repository name was changed + if (sanitizedRepoName !== repoName && sanitizedRepoName !== repoName.toLowerCase()) { + toast.info(`Repository name sanitized to "${sanitizedRepoName}" to meet GitLab requirements`); + } + + // Check if project exists using the sanitized name + const projectPath = `${connection.user.username}/${sanitizedRepoName}`; const existingProject = await apiService.getProjectByPath(projectPath); const projectExists = existingProject !== null; @@ -131,7 +145,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: : ''; const confirmOverwrite = window.confirm( - `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.${visibilityChange}`, + `Repository "${sanitizedRepoName}" already exists. Do you want to update it? This will add or modify files in the repository.${visibilityChange}`, ); if (!confirmOverwrite) { @@ -154,7 +168,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: // Create new project with files toast.info('Creating new repository...'); - const newProject = await apiService.createProjectWithFiles(repoName, isPrivate, files); + const newProject = await apiService.createProjectWithFiles(sanitizedRepoName, isPrivate, files); setCreatedRepoUrl(newProject.http_url_to_repo); toast.success('Repository created successfully!'); } @@ -173,7 +187,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: `gitlab-repo-${currentChatId}`, JSON.stringify({ owner: connection.user.username, - name: repoName, + name: sanitizedRepoName, url: createdRepoUrl, }), ); @@ -181,17 +195,18 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: logStore.logInfo('GitLab deployment completed successfully', { type: 'system', message: `Successfully deployed ${fileList.length} files to ${projectExists ? 'existing' : 'new'} GitLab repository: ${projectPath}`, - repoName, + repoName: sanitizedRepoName, projectPath, filesCount: fileList.length, isNewProject: !projectExists, }); } catch (error) { console.error('Error pushing to GitLab:', error); + logStore.logError('GitLab deployment failed', { error, - repoName, - projectPath: `${connection.user.username}/${repoName}`, + repoName: sanitizedRepoName, + projectPath: `${connection.user.username}/${sanitizedRepoName}`, }); // Provide specific error messages based on error type @@ -233,6 +248,18 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: onClose(); }; + const handleAuthDialogClose = () => { + setShowAuthDialog(false); + + // Refresh user data after auth + const connection = getLocalStorage('gitlab_connection'); + + if (connection?.user && connection?.token) { + setUser(connection.user); + fetchRecentRepos(connection.token, connection.gitlabUrl || 'https://gitlab.com'); + } + }; + // Success Dialog if (showSuccessDialog) { return ( @@ -425,21 +452,24 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: > Close - setShowAuthDialog(true)} className="px-4 py-2 rounded-lg bg-orange-500 text-white text-sm hover:bg-orange-600 inline-flex items-center gap-2" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} > -
- Go to Settings - +
+ Connect GitLab Account +
+ + {/* GitLab Auth Dialog */} + ); } @@ -499,6 +529,8 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: src={user.avatar_url} alt={user.username} className="w-10 h-10 rounded-full object-cover" + crossOrigin="anonymous" + referrerPolicy="no-referrer" onError={(e) => { // Handle CORS/COEP errors by hiding the image and showing fallback const target = e.target as HTMLImageElement; @@ -724,6 +756,9 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
+ + {/* GitLab Auth Dialog */} + ); } diff --git a/app/components/ui/BranchSelector.tsx b/app/components/ui/BranchSelector.tsx new file mode 100644 index 0000000..235eb6f --- /dev/null +++ b/app/components/ui/BranchSelector.tsx @@ -0,0 +1,270 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Button } from './Button'; +import { classNames } from '~/utils/classNames'; +import { GitBranch, Check, Shield, Star, RefreshCw, X } from 'lucide-react'; + +interface BranchInfo { + name: string; + sha: string; + protected: boolean; + isDefault: boolean; + canPush?: boolean; // GitLab specific +} + +interface BranchSelectorProps { + provider: 'github' | 'gitlab'; + repoOwner: string; + repoName: string; + projectId?: string | number; // GitLab specific + token: string; + gitlabUrl?: string; + defaultBranch?: string; + onBranchSelect: (branch: string) => void; + onClose: () => void; + isOpen: boolean; + className?: string; +} + +export function BranchSelector({ + provider, + repoOwner, + repoName, + projectId, + token, + gitlabUrl, + defaultBranch, + onBranchSelect, + onClose, + isOpen, + className, +}: BranchSelectorProps) { + const [branches, setBranches] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedBranch, setSelectedBranch] = useState(''); + + const filteredBranches = branches.filter((branch) => branch.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const fetchBranches = async () => { + setIsLoading(true); + setError(null); + + try { + let response: Response; + + if (provider === 'github') { + response = await fetch('/api/github-branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner: repoOwner, + repo: repoName, + token, + }), + }); + } else { + // GitLab + if (!projectId) { + throw new Error('Project ID is required for GitLab repositories'); + } + + response = await fetch('/api/gitlab-branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token, + gitlabUrl: gitlabUrl || 'https://gitlab.com', + projectId, + }), + }); + } + + if (!response.ok) { + const errorData: any = await response.json().catch(() => ({ error: 'Failed to fetch branches' })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + const data: any = await response.json(); + setBranches(data.branches || []); + + // Set default selected branch + const defaultBranchToSelect = data.defaultBranch || defaultBranch || 'main'; + setSelectedBranch(defaultBranchToSelect); + } catch (err) { + console.error('Failed to fetch branches:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch branches'); + setBranches([]); + } finally { + setIsLoading(false); + } + }; + + const handleBranchSelect = (branchName: string) => { + setSelectedBranch(branchName); + }; + + const handleConfirmSelection = () => { + onBranchSelect(selectedBranch); + onClose(); + }; + + useEffect(() => { + if (isOpen && !branches.length) { + fetchBranches(); + } + }, [isOpen, repoOwner, repoName, projectId]); + + // Reset search when closing + useEffect(() => { + if (!isOpen) { + setSearchQuery(''); + } + }, [isOpen]); + + if (!isOpen) { + return null; + } + + return ( + +
+ + {/* Header */} +
+
+
+ +
+
+

Select Branch

+

+ {repoOwner}/{repoName} +

+
+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+

Loading branches...

+
+ ) : error ? ( +
+
+ +
+

{error}

+ +
+ ) : ( + <> + {/* Search */} + {branches.length > 10 && ( +
+ setSearchQuery(e.target.value)} + className="w-full px-3 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" + /> +
+ )} + + {/* Branch List */} +
+ {filteredBranches.length > 0 ? ( +
+ {filteredBranches.map((branch) => ( + + ))} +
+ ) : ( +
+

+ {searchQuery ? 'No branches found matching your search.' : 'No branches available.'} +

+
+ )} +
+ + )} +
+ + {/* Footer */} + {!isLoading && !error && branches.length > 0 && ( +
+
+ {selectedBranch && ( + <> + Selected: {selectedBranch} + + )} +
+
+ + +
+
+ )} + +
+ + ); +} diff --git a/app/components/ui/GlowingEffect.tsx b/app/components/ui/GlowingEffect.tsx index 5c8a3e1..3344413 100644 --- a/app/components/ui/GlowingEffect.tsx +++ b/app/components/ui/GlowingEffect.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useRef } from 'react'; -import { cn } from '~/utils/cn'; +import { classNames } from '~/utils/classNames'; import { animate } from 'framer-motion'; interface GlowingEffectProps { @@ -122,7 +122,7 @@ const GlowingEffect = memo( return ( <>