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