Compare commits
19 Commits
main
...
slate-rebr
| Author | SHA1 | Date | |
|---|---|---|---|
| 713a3db8ce | |||
| d9eea2adaf | |||
| 6fd45ff34c | |||
|
|
5d3fb1d7de | ||
|
|
c1474d85e5 | ||
|
|
3779000ec5 | ||
|
|
9c0e898d17 | ||
|
|
dee7fb0545 | ||
|
|
0e31267078 | ||
|
|
332edd3d98 | ||
|
|
7045646b1a | ||
|
|
eb4aa68581 | ||
|
|
73710da501 | ||
|
|
a87eae2119 | ||
|
|
ecf3cad189 | ||
|
|
be7a754a74 | ||
|
|
2a585ab5ea | ||
|
|
eac1933094 | ||
|
|
c36ab43dcc |
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"ignoreMatches": [
|
||||
"@types/*",
|
||||
"eslint-*",
|
||||
"prettier*",
|
||||
"husky",
|
||||
"rimraf",
|
||||
"vitest",
|
||||
"vite",
|
||||
"typescript",
|
||||
"wrangler",
|
||||
"electron*"
|
||||
],
|
||||
"ignoreDirs": [
|
||||
"dist",
|
||||
"build",
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"skipMissing": false,
|
||||
"ignorePatterns": [
|
||||
"*.d.ts",
|
||||
"*.test.ts",
|
||||
"*.test.tsx",
|
||||
"*.spec.ts",
|
||||
"*.spec.tsx"
|
||||
]
|
||||
}
|
||||
295
.env.example
295
.env.example
@@ -1,221 +1,122 @@
|
||||
# ======================================
|
||||
# Environment Variables for Bolt.diy
|
||||
# ======================================
|
||||
# Copy this file to .env.local and fill in your API keys
|
||||
# See README.md for setup instructions
|
||||
# Rename this file to .env once you have filled in the below environment variables!
|
||||
|
||||
# ======================================
|
||||
# AI PROVIDER API KEYS
|
||||
# ======================================
|
||||
# Get your GROQ API Key here -
|
||||
# https://console.groq.com/keys
|
||||
# You only need this environment variable set if you want to use Groq models
|
||||
GROQ_API_KEY=
|
||||
|
||||
# Anthropic Claude
|
||||
# Get your API key from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# Get your HuggingFace API Key here -
|
||||
# https://huggingface.co/settings/tokens
|
||||
# You only need this environment variable set if you want to use HuggingFace models
|
||||
HuggingFace_API_KEY=
|
||||
|
||||
# Cerebras (High-performance inference)
|
||||
# Get your API key from: https://cloud.cerebras.ai/settings
|
||||
CEREBRAS_API_KEY=your_cerebras_api_key_here
|
||||
|
||||
# Fireworks AI (Fast inference with FireAttention engine)
|
||||
# Get your API key from: https://fireworks.ai/api-keys
|
||||
FIREWORKS_API_KEY=your_fireworks_api_key_here
|
||||
# Get your Open AI API Key by following these instructions -
|
||||
# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key
|
||||
# You only need this environment variable set if you want to use GPT models
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# OpenAI GPT models
|
||||
# Get your API key from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
# Get your Anthropic API Key in your account settings -
|
||||
# https://console.anthropic.com/settings/keys
|
||||
# You only need this environment variable set if you want to use Claude models
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# GitHub Models (OpenAI models hosted by GitHub)
|
||||
# Get your Personal Access Token from: https://github.com/settings/tokens
|
||||
# - Select "Fine-grained tokens"
|
||||
# - Set repository access to "All repositories"
|
||||
# - Enable "GitHub Models" permission
|
||||
GITHUB_API_KEY=github_pat_your_personal_access_token_here
|
||||
# Get your OpenRouter API Key in your account settings -
|
||||
# https://openrouter.ai/settings/keys
|
||||
# You only need this environment variable set if you want to use OpenRouter models
|
||||
OPEN_ROUTER_API_KEY=
|
||||
|
||||
# Perplexity AI (Search-augmented models)
|
||||
# Get your API key from: https://www.perplexity.ai/settings/api
|
||||
PERPLEXITY_API_KEY=your_perplexity_api_key_here
|
||||
# Get your Google Generative AI API Key by following these instructions -
|
||||
# https://console.cloud.google.com/apis/credentials
|
||||
# You only need this environment variable set if you want to use Google Generative AI models
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
|
||||
# DeepSeek
|
||||
# Get your API key from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
# You only need this environment variable set if you want to use oLLAMA models
|
||||
# DONT USE http://localhost:11434 due to IPV6 issues
|
||||
# USE EXAMPLE http://127.0.0.1:11434
|
||||
OLLAMA_API_BASE_URL=
|
||||
|
||||
# Google Gemini
|
||||
# Get your API key from: https://makersuite.google.com/app/apikey
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=your_google_gemini_api_key_here
|
||||
# You only need this environment variable set if you want to use OpenAI Like models
|
||||
OPENAI_LIKE_API_BASE_URL=
|
||||
|
||||
# Cohere
|
||||
# Get your API key from: https://dashboard.cohere.ai/api-keys
|
||||
COHERE_API_KEY=your_cohere_api_key_here
|
||||
# You only need this environment variable set if you want to use Together AI models
|
||||
TOGETHER_API_BASE_URL=
|
||||
|
||||
# Groq (Fast inference)
|
||||
# Get your API key from: https://console.groq.com/keys
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
# You only need this environment variable set if you want to use DeepSeek models through their API
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# Mistral
|
||||
# Get your API key from: https://console.mistral.ai/api-keys/
|
||||
MISTRAL_API_KEY=your_mistral_api_key_here
|
||||
# Get your OpenAI Like API Key
|
||||
OPENAI_LIKE_API_KEY=
|
||||
|
||||
# Together AI
|
||||
# Get your API key from: https://api.together.xyz/settings/api-keys
|
||||
TOGETHER_API_KEY=your_together_api_key_here
|
||||
# Get your Together API Key
|
||||
TOGETHER_API_KEY=
|
||||
|
||||
# X.AI (Elon Musk's company)
|
||||
# Get your API key from: https://console.x.ai/
|
||||
XAI_API_KEY=your_xai_api_key_here
|
||||
# You only need this environment variable set if you want to use Hyperbolic models
|
||||
#Get your Hyperbolics API Key at https://app.hyperbolic.xyz/settings
|
||||
#baseURL="https://api.hyperbolic.xyz/v1/chat/completions"
|
||||
HYPERBOLIC_API_KEY=
|
||||
HYPERBOLIC_API_BASE_URL=
|
||||
|
||||
# Moonshot AI (Kimi models)
|
||||
# Get your API key from: https://platform.moonshot.ai/console/api-keys
|
||||
MOONSHOT_API_KEY=your_moonshot_api_key_here
|
||||
# Get your Mistral API Key by following these instructions -
|
||||
# https://console.mistral.ai/api-keys/
|
||||
# You only need this environment variable set if you want to use Mistral models
|
||||
MISTRAL_API_KEY=
|
||||
|
||||
# Z.AI (GLM models with JWT authentication)
|
||||
# Get your API key from: https://open.bigmodel.cn/usercenter/apikeys
|
||||
ZAI_API_KEY=your_zai_api_key_here
|
||||
# Get the Cohere Api key by following these instructions -
|
||||
# https://dashboard.cohere.com/api-keys
|
||||
# You only need this environment variable set if you want to use Cohere models
|
||||
COHERE_API_KEY=
|
||||
|
||||
# Hugging Face
|
||||
# Get your API key from: https://huggingface.co/settings/tokens
|
||||
HuggingFace_API_KEY=your_huggingface_api_key_here
|
||||
# Get LMStudio Base URL from LM Studio Developer Console
|
||||
# Make sure to enable CORS
|
||||
# DONT USE http://localhost:1234 due to IPV6 issues
|
||||
# Example: http://127.0.0.1:1234
|
||||
LMSTUDIO_API_BASE_URL=
|
||||
|
||||
# Hyperbolic
|
||||
# Get your API key from: https://app.hyperbolic.xyz/settings
|
||||
HYPERBOLIC_API_KEY=your_hyperbolic_api_key_here
|
||||
# Get your xAI API key
|
||||
# https://x.ai/api
|
||||
# You only need this environment variable set if you want to use xAI models
|
||||
XAI_API_KEY=
|
||||
|
||||
# OpenRouter (Meta routing for multiple providers)
|
||||
# Get your API key from: https://openrouter.ai/keys
|
||||
OPEN_ROUTER_API_KEY=your_openrouter_api_key_here
|
||||
# Get your Perplexity API Key here -
|
||||
# https://www.perplexity.ai/settings/api
|
||||
# You only need this environment variable set if you want to use Perplexity models
|
||||
PERPLEXITY_API_KEY=
|
||||
|
||||
# ======================================
|
||||
# CUSTOM PROVIDER BASE URLS (Optional)
|
||||
# ======================================
|
||||
# Get your AWS configuration
|
||||
# https://console.aws.amazon.com/iam/home
|
||||
# The JSON should include the following keys:
|
||||
# - region: The AWS region where Bedrock is available.
|
||||
# - accessKeyId: Your AWS access key ID.
|
||||
# - secretAccessKey: Your AWS secret access key.
|
||||
# - sessionToken (optional): Temporary session token if using an IAM role or temporary credentials.
|
||||
# Example JSON:
|
||||
# {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey", "sessionToken": "yourSessionToken"}
|
||||
AWS_BEDROCK_CONFIG=
|
||||
|
||||
# Ollama (Local models)
|
||||
# DON'T USE http://localhost:11434 due to IPv6 issues
|
||||
# USE: http://127.0.0.1:11434
|
||||
OLLAMA_API_BASE_URL=http://127.0.0.1:11434
|
||||
|
||||
# OpenAI-like API (Compatible providers)
|
||||
OPENAI_LIKE_API_BASE_URL=your_openai_like_base_url_here
|
||||
OPENAI_LIKE_API_KEY=your_openai_like_api_key_here
|
||||
|
||||
# Together AI Base URL
|
||||
TOGETHER_API_BASE_URL=your_together_base_url_here
|
||||
|
||||
# Hyperbolic Base URL
|
||||
HYPERBOLIC_API_BASE_URL=https://api.hyperbolic.xyz/v1/chat/completions
|
||||
|
||||
# LMStudio (Local models)
|
||||
# Make sure to enable CORS in LMStudio
|
||||
# DON'T USE http://localhost:1234 due to IPv6 issues
|
||||
# USE: http://127.0.0.1:1234
|
||||
LMSTUDIO_API_BASE_URL=http://127.0.0.1:1234
|
||||
|
||||
# ======================================
|
||||
# CLOUD SERVICES CONFIGURATION
|
||||
# ======================================
|
||||
|
||||
# AWS Bedrock Configuration (JSON format)
|
||||
# Get your credentials from: https://console.aws.amazon.com/iam/home
|
||||
# Example: {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey"}
|
||||
AWS_BEDROCK_CONFIG=your_aws_bedrock_config_json_here
|
||||
|
||||
# ======================================
|
||||
# GITHUB INTEGRATION
|
||||
# ======================================
|
||||
|
||||
# GitHub Personal Access Token
|
||||
# Get from: https://github.com/settings/tokens
|
||||
# Used for importing/cloning repositories and accessing private repos
|
||||
VITE_GITHUB_ACCESS_TOKEN=your_github_personal_access_token_here
|
||||
|
||||
# GitHub Token Type ('classic' or 'fine-grained')
|
||||
VITE_GITHUB_TOKEN_TYPE=classic
|
||||
|
||||
# ======================================
|
||||
# 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=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
|
||||
|
||||
# GitLab token type should be 'personal-access-token'
|
||||
VITE_GITLAB_TOKEN_TYPE=personal-access-token
|
||||
|
||||
# ======================================
|
||||
# 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
|
||||
# ======================================
|
||||
|
||||
# Development Mode
|
||||
NODE_ENV=development
|
||||
|
||||
# Application Port (optional, defaults to 5173 for development)
|
||||
PORT=5173
|
||||
|
||||
# Logging Level (debug, info, warn, error)
|
||||
# Include this environment variable if you want more logging for debugging locally
|
||||
VITE_LOG_LEVEL=debug
|
||||
|
||||
# Default Context Window Size (for local models)
|
||||
DEFAULT_NUM_CTX=32768
|
||||
# Get your GitHub Personal Access Token here -
|
||||
# https://github.com/settings/tokens
|
||||
# This token is used for:
|
||||
# 1. Importing/cloning GitHub repositories without rate limiting
|
||||
# 2. Accessing private repositories
|
||||
# 3. Automatic GitHub authentication (no need to manually connect in the UI)
|
||||
#
|
||||
# For classic tokens, ensure it has these scopes: repo, read:org, read:user
|
||||
# For fine-grained tokens, ensure it has Repository and Organization access
|
||||
VITE_GITHUB_ACCESS_TOKEN=
|
||||
|
||||
# ======================================
|
||||
# SETUP INSTRUCTIONS
|
||||
# ======================================
|
||||
# 1. Copy this file to .env.local: cp .env.example .env.local
|
||||
# 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
|
||||
# Specify the type of GitHub token you're using
|
||||
# Can be 'classic' or 'fine-grained'
|
||||
# Classic tokens are recommended for broader access
|
||||
VITE_GITHUB_TOKEN_TYPE=classic
|
||||
|
||||
# Example Context Values for qwen2.5-coder:32b
|
||||
#
|
||||
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
||||
# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM
|
||||
# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM
|
||||
# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM
|
||||
DEFAULT_NUM_CTX=
|
||||
|
||||
@@ -103,36 +103,9 @@ VITE_GITHUB_ACCESS_TOKEN=
|
||||
# Classic tokens are recommended for broader access
|
||||
VITE_GITHUB_TOKEN_TYPE=
|
||||
|
||||
# ======================================
|
||||
# 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
|
||||
# Netlify Authentication
|
||||
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
|
||||
|
||||
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"rules": {
|
||||
// example: turn off console warnings
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
|
||||
30
.github/CODEOWNERS
vendored
30
.github/CODEOWNERS
vendored
@@ -1,30 +0,0 @@
|
||||
# Code Owners for bolt.diy
|
||||
# These users/teams will automatically be requested for review when files are modified
|
||||
|
||||
# Global ownership - repository maintainers
|
||||
* @stackblitz-labs/bolt-maintainers
|
||||
|
||||
# GitHub workflows and CI/CD configuration - require maintainer review
|
||||
/.github/ @stackblitz-labs/bolt-maintainers
|
||||
/package.json @stackblitz-labs/bolt-maintainers
|
||||
/pnpm-lock.yaml @stackblitz-labs/bolt-maintainers
|
||||
|
||||
# Security-sensitive configurations - require maintainer review
|
||||
/.env* @stackblitz-labs/bolt-maintainers
|
||||
/wrangler.toml @stackblitz-labs/bolt-maintainers
|
||||
/Dockerfile @stackblitz-labs/bolt-maintainers
|
||||
/docker-compose.yaml @stackblitz-labs/bolt-maintainers
|
||||
|
||||
# Core application architecture - require maintainer review
|
||||
/app/lib/.server/ @stackblitz-labs/bolt-maintainers
|
||||
/app/routes/api.* @stackblitz-labs/bolt-maintainers
|
||||
|
||||
# Build and deployment configuration - require maintainer review
|
||||
/vite*.config.ts @stackblitz-labs/bolt-maintainers
|
||||
/tsconfig.json @stackblitz-labs/bolt-maintainers
|
||||
/uno.config.ts @stackblitz-labs/bolt-maintainers
|
||||
/eslint.config.mjs @stackblitz-labs/bolt-maintainers
|
||||
|
||||
# Documentation (optional review)
|
||||
/*.md
|
||||
/docs/
|
||||
4
.github/actions/setup-and-build/action.yaml
vendored
4
.github/actions/setup-and-build/action.yaml
vendored
@@ -4,11 +4,11 @@ inputs:
|
||||
pnpm-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '9.14.4'
|
||||
default: '9.4.0'
|
||||
node-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '20.18.0'
|
||||
default: '20.15.1'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
69
.github/workflows/ci.yaml
vendored
69
.github/workflows/ci.yaml
vendored
@@ -3,20 +3,13 @@ name: CI/CD
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
# Cancel in-progress runs on the same branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,67 +17,11 @@ jobs:
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Cache TypeScript compilation
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.tsbuildinfo
|
||||
node_modules/.cache
|
||||
key: ${{ runner.os }}-typescript-${{ hashFiles('**/tsconfig.json', 'app/**/*.ts', 'app/**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-typescript-
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Cache ESLint
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/.cache/eslint
|
||||
key: ${{ runner.os }}-eslint-${{ hashFiles('.eslintrc*', 'app/**/*.ts', 'app/**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-eslint-
|
||||
|
||||
- name: Run ESLint
|
||||
run: pnpm run lint
|
||||
# - name: Run ESLint
|
||||
# run: pnpm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
- name: Upload test coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
docker-validation:
|
||||
name: Docker Build Validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Validate Docker production build
|
||||
run: |
|
||||
echo "🐳 Testing Docker production target..."
|
||||
docker build --target bolt-ai-production . --no-cache --progress=plain
|
||||
echo "✅ Production target builds successfully"
|
||||
|
||||
- name: Validate Docker development build
|
||||
run: |
|
||||
echo "🐳 Testing Docker development target..."
|
||||
docker build --target development . --no-cache --progress=plain
|
||||
echo "✅ Development target builds successfully"
|
||||
|
||||
- name: Validate docker-compose configuration
|
||||
run: |
|
||||
echo "🐳 Validating docker-compose configuration..."
|
||||
docker compose config --quiet
|
||||
echo "✅ docker-compose configuration is valid"
|
||||
|
||||
11
.github/workflows/docker.yaml
vendored
11
.github/workflows/docker.yaml
vendored
@@ -13,10 +13,10 @@ concurrency:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
docker-build-publish:
|
||||
@@ -26,10 +26,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set lowercase image name
|
||||
id: image
|
||||
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -44,7 +40,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }}
|
||||
@@ -62,6 +58,5 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
- name: Check manifest
|
||||
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}
|
||||
run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
4
.github/workflows/electron.yml
vendored
4
.github/workflows/electron.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest] # Use unsigned macOS builds for now
|
||||
node-version: [20.18.0]
|
||||
node-version: [18.18.0]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
98
.github/workflows/pr-release-validation.yaml
vendored
98
.github/workflows/pr-release-validation.yaml
vendored
@@ -6,79 +6,12 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
quality-gates:
|
||||
name: Quality Gates
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Wait for CI checks
|
||||
uses: lewagon/wait-on-check-action@v1.3.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
check-name: 'Test'
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
wait-interval: 10
|
||||
|
||||
- name: Check required status checks
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
const { data: checks } = await github.rest.checks.listForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: context.payload.pull_request.head.sha
|
||||
});
|
||||
|
||||
const requiredChecks = ['Test', 'CodeQL Analysis'];
|
||||
const optionalChecks = ['Quality Analysis', 'Deploy Preview'];
|
||||
const failedChecks = [];
|
||||
const passedChecks = [];
|
||||
|
||||
// Check required workflows
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = checks.check_runs.find(c => c.name === checkName);
|
||||
if (check && check.conclusion === 'success') {
|
||||
passedChecks.push(checkName);
|
||||
} else {
|
||||
failedChecks.push(checkName);
|
||||
}
|
||||
}
|
||||
|
||||
// Report optional checks
|
||||
for (const checkName of optionalChecks) {
|
||||
const check = checks.check_runs.find(c => c.name === checkName);
|
||||
if (check && check.conclusion === 'success') {
|
||||
passedChecks.push(`${checkName} (optional)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Passed checks: ${passedChecks.join(', ')}`);
|
||||
|
||||
if (failedChecks.length > 0) {
|
||||
console.log(`❌ Failed required checks: ${failedChecks.join(', ')}`);
|
||||
core.setFailed(`Required checks failed: ${failedChecks.join(', ')}`);
|
||||
} else {
|
||||
console.log(`✅ All required checks passed!`);
|
||||
}
|
||||
|
||||
validate-release:
|
||||
name: Release Validation
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality-gates
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate PR Labels
|
||||
run: |
|
||||
@@ -96,30 +29,3 @@ jobs:
|
||||
else
|
||||
echo "This PR doesn't have the stable-release label. No release will be created."
|
||||
fi
|
||||
|
||||
- name: Check breaking changes
|
||||
if: contains(github.event.pull_request.labels.*.name, 'major')
|
||||
run: |
|
||||
echo "⚠️ This PR contains breaking changes and will trigger a major release."
|
||||
|
||||
- name: Validate changelog entry
|
||||
if: contains(github.event.pull_request.labels.*.name, 'stable-release')
|
||||
run: |
|
||||
if ! grep -q "${{ github.event.pull_request.number }}" CHANGES.md; then
|
||||
echo "❌ No changelog entry found for PR #${{ github.event.pull_request.number }}"
|
||||
echo "Please add an entry to CHANGES.md"
|
||||
exit 1
|
||||
else
|
||||
echo "✓ Changelog entry found"
|
||||
fi
|
||||
|
||||
security-review:
|
||||
name: Security Review Required
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'security')
|
||||
|
||||
steps:
|
||||
- name: Check security label
|
||||
run: |
|
||||
echo "🔒 This PR has security implications and requires additional review"
|
||||
echo "Ensure a security team member has approved this PR before merging"
|
||||
|
||||
196
.github/workflows/preview.yaml
vendored
196
.github/workflows/preview.yaml
vendored
@@ -1,196 +0,0 @@
|
||||
name: Preview Deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs on the same PR
|
||||
concurrency:
|
||||
group: preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
deployments: write
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action != 'closed'
|
||||
|
||||
steps:
|
||||
- name: Check if preview deployment is configured
|
||||
id: check-secrets
|
||||
run: |
|
||||
if [[ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" && -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]]; then
|
||||
echo "configured=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "configured=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
if: steps.check-secrets.outputs.configured == 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
if: steps.check-secrets.outputs.configured == 'true'
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Build for production
|
||||
if: steps.check-secrets.outputs.configured == 'true'
|
||||
run: pnpm run build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
if: steps.check-secrets.outputs.configured == 'true'
|
||||
id: deploy
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: bolt-diy-preview
|
||||
directory: build/client
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Preview deployment not configured
|
||||
if: steps.check-secrets.outputs.configured == 'false'
|
||||
run: |
|
||||
echo "✅ Preview deployment is not configured for this repository"
|
||||
echo "To enable preview deployments, add the following secrets:"
|
||||
echo "- CLOUDFLARE_API_TOKEN"
|
||||
echo "- CLOUDFLARE_ACCOUNT_ID"
|
||||
echo "This is optional and the workflow will pass without it."
|
||||
echo "url=https://preview-not-configured.example.com" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Add preview URL comment to PR
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const previewComment = comments.find(comment =>
|
||||
comment.body.includes('🚀 Preview deployment')
|
||||
);
|
||||
|
||||
const isConfigured = '${{ steps.check-secrets.outputs.configured }}' === 'true';
|
||||
const deployUrl = '${{ steps.deploy.outputs.url }}' || 'https://preview-not-configured.example.com';
|
||||
|
||||
let commentBody;
|
||||
if (isConfigured) {
|
||||
commentBody = `🚀 Preview deployment is ready!
|
||||
|
||||
| Name | Link |
|
||||
|------|------|
|
||||
| Latest commit | ${{ github.sha }} |
|
||||
| Preview URL | ${deployUrl} |
|
||||
|
||||
Built with ❤️ by [bolt.diy](https://bolt.diy)
|
||||
`;
|
||||
} else {
|
||||
commentBody = `ℹ️ Preview deployment not configured
|
||||
|
||||
| Name | Info |
|
||||
|------|------|
|
||||
| Latest commit | ${{ github.sha }} |
|
||||
| Status | Preview deployment requires Cloudflare secrets |
|
||||
|
||||
To enable preview deployments, repository maintainers can add:
|
||||
- \`CLOUDFLARE_API_TOKEN\` secret
|
||||
- \`CLOUDFLARE_ACCOUNT_ID\` secret
|
||||
|
||||
Built with ❤️ by [bolt.diy](https://bolt.diy)
|
||||
`;
|
||||
}
|
||||
|
||||
if (previewComment) {
|
||||
github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: previewComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
} else {
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
|
||||
- name: Run smoke tests on preview
|
||||
run: |
|
||||
if [[ "${{ steps.check-secrets.outputs.configured }}" == "true" ]]; then
|
||||
echo "Running smoke tests on preview deployment..."
|
||||
echo "Preview URL: ${{ steps.deploy.outputs.url }}"
|
||||
# Basic HTTP check instead of Playwright tests
|
||||
curl -f ${{ steps.deploy.outputs.url }} || echo "Preview environment check completed"
|
||||
else
|
||||
echo "✅ Smoke tests skipped - preview deployment not configured"
|
||||
echo "This is normal and expected when Cloudflare secrets are not available"
|
||||
fi
|
||||
|
||||
- name: Preview workflow summary
|
||||
run: |
|
||||
echo "✅ Preview deployment workflow completed successfully"
|
||||
if [[ "${{ steps.check-secrets.outputs.configured }}" == "true" ]]; then
|
||||
echo "🚀 Preview deployed to: ${{ steps.deploy.outputs.url }}"
|
||||
else
|
||||
echo "ℹ️ Preview deployment not configured (this is normal)"
|
||||
fi
|
||||
|
||||
cleanup-preview:
|
||||
name: Cleanup Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action == 'closed'
|
||||
|
||||
steps:
|
||||
- name: Delete preview environment
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
const deployments = await github.rest.repos.listDeployments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
environment: `preview-pr-${{ github.event.pull_request.number }}`,
|
||||
});
|
||||
|
||||
for (const deployment of deployments.data) {
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: deployment.id,
|
||||
state: 'inactive',
|
||||
});
|
||||
}
|
||||
|
||||
- name: Remove preview comment
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
for (const comment of comments) {
|
||||
if (comment.body.includes('🚀 Preview deployment')) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
181
.github/workflows/quality.yaml
vendored
181
.github/workflows/quality.yaml
vendored
@@ -1,181 +0,0 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs on the same branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
name: Quality Analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Check for duplicate dependencies
|
||||
run: |
|
||||
echo "Checking for duplicate dependencies..."
|
||||
pnpm dedupe --check || echo "✅ Duplicate dependency check completed"
|
||||
|
||||
- name: Check bundle size
|
||||
run: |
|
||||
pnpm run build
|
||||
echo "Bundle analysis completed (bundlesize tool requires configuration)"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Dead code elimination check
|
||||
run: |
|
||||
echo "Checking for unused imports and dead code..."
|
||||
npx unimported || echo "Unimported tool completed with warnings"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check for unused dependencies
|
||||
run: |
|
||||
echo "Checking for unused dependencies..."
|
||||
npx depcheck --config .depcheckrc.json || echo "Dependency check completed with findings"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check package.json formatting
|
||||
run: |
|
||||
echo "Checking package.json formatting..."
|
||||
npx sort-package-json package.json --check || echo "Package.json formatting check completed"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Generate complexity report
|
||||
run: |
|
||||
echo "Analyzing code complexity..."
|
||||
npx es6-plato -r -d complexity-report app/ || echo "Complexity analysis completed"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload complexity report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: complexity-report
|
||||
path: complexity-report/
|
||||
retention-days: 7
|
||||
|
||||
accessibility-tests:
|
||||
name: Accessibility Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Start development server
|
||||
run: |
|
||||
pnpm run build
|
||||
pnpm run start &
|
||||
sleep 15
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run accessibility tests with axe
|
||||
run: |
|
||||
echo "Running accessibility tests..."
|
||||
npx @axe-core/cli http://localhost:5173 --exit || echo "Accessibility tests completed with findings"
|
||||
continue-on-error: true
|
||||
|
||||
performance-audit:
|
||||
name: Performance Audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Start server for Lighthouse
|
||||
run: |
|
||||
pnpm run build
|
||||
pnpm run start &
|
||||
sleep 20
|
||||
|
||||
- name: Run Lighthouse audit
|
||||
run: |
|
||||
echo "Running Lighthouse performance audit..."
|
||||
npx lighthouse http://localhost:5173 --output-path=./lighthouse-report.html --output=html --chrome-flags="--headless --no-sandbox" || echo "Lighthouse audit completed"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Lighthouse report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: lighthouse-report
|
||||
path: lighthouse-report.html
|
||||
retention-days: 7
|
||||
|
||||
pr-size-check:
|
||||
name: PR Size Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Calculate PR size
|
||||
id: pr-size
|
||||
run: |
|
||||
# Get the base branch (target branch)
|
||||
BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# Count additions and deletions
|
||||
ADDITIONS=$(git diff --numstat origin/$BASE_BRANCH...HEAD | awk '{sum += $1} END {print sum}')
|
||||
DELETIONS=$(git diff --numstat origin/$BASE_BRANCH...HEAD | awk '{sum += $2} END {print sum}')
|
||||
TOTAL_CHANGES=$((ADDITIONS + DELETIONS))
|
||||
|
||||
echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT
|
||||
echo "deletions=$DELETIONS" >> $GITHUB_OUTPUT
|
||||
echo "total=$TOTAL_CHANGES" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine size category
|
||||
if [ $TOTAL_CHANGES -lt 50 ]; then
|
||||
echo "size=XS" >> $GITHUB_OUTPUT
|
||||
elif [ $TOTAL_CHANGES -lt 200 ]; then
|
||||
echo "size=S" >> $GITHUB_OUTPUT
|
||||
elif [ $TOTAL_CHANGES -lt 500 ]; then
|
||||
echo "size=M" >> $GITHUB_OUTPUT
|
||||
elif [ $TOTAL_CHANGES -lt 1000 ]; then
|
||||
echo "size=L" >> $GITHUB_OUTPUT
|
||||
elif [ $TOTAL_CHANGES -lt 2000 ]; then
|
||||
echo "size=XL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "size=XXL" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: PR size summary
|
||||
run: |
|
||||
echo "✅ PR Size Analysis Complete"
|
||||
echo "📊 Changes: +${{ steps.pr-size.outputs.additions }} -${{ steps.pr-size.outputs.deletions }}"
|
||||
echo "📏 Size Category: ${{ steps.pr-size.outputs.size }}"
|
||||
echo "💡 This information helps reviewers understand the scope of changes"
|
||||
|
||||
if [ "${{ steps.pr-size.outputs.size }}" = "XXL" ]; then
|
||||
echo "ℹ️ This is a large PR - consider breaking it into smaller chunks for future PRs"
|
||||
echo "However, large PRs are acceptable for major feature additions like this one"
|
||||
fi
|
||||
121
.github/workflows/security.yaml
vendored
121
.github/workflows/security.yaml
vendored
@@ -1,121 +0,0 @@
|
||||
name: Security Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Run weekly security scan on Sundays at 2 AM
|
||||
- cron: '0 2 * * 0'
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: read
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript', 'typescript']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
upload: false
|
||||
output: "codeql-results"
|
||||
|
||||
- name: Upload CodeQL results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: codeql-results-${{ matrix.language }}
|
||||
path: codeql-results
|
||||
|
||||
dependency-scan:
|
||||
name: Dependency Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.18.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.14.4'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run npm audit
|
||||
run: pnpm audit --audit-level moderate
|
||||
continue-on-error: true
|
||||
|
||||
- name: Generate SBOM
|
||||
uses: anchore/sbom-action@v0
|
||||
with:
|
||||
path: ./
|
||||
format: spdx-json
|
||||
artifact-name: sbom.spdx.json
|
||||
|
||||
- name: Upload SBOM as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: sbom-results
|
||||
path: |
|
||||
sbom.spdx.json
|
||||
**/sbom.spdx.json
|
||||
|
||||
secrets-scan:
|
||||
name: Secrets Detection
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Trivy secrets scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-secrets-results.sarif'
|
||||
scanners: 'secret'
|
||||
|
||||
- name: Upload Trivy secrets results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: trivy-secrets-results
|
||||
path: trivy-secrets-results.sarif
|
||||
|
||||
247
.github/workflows/test-workflows.yaml
vendored
247
.github/workflows/test-workflows.yaml
vendored
@@ -1,247 +0,0 @@
|
||||
name: Test Workflows
|
||||
|
||||
# This workflow is for testing our new workflow changes safely
|
||||
on:
|
||||
push:
|
||||
branches: [workflow-testing, test-*]
|
||||
pull_request:
|
||||
branches: [workflow-testing]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_type:
|
||||
description: 'Type of test to run'
|
||||
required: true
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- ci-only
|
||||
- security-only
|
||||
- quality-only
|
||||
|
||||
jobs:
|
||||
workflow-test-info:
|
||||
name: Workflow Test Information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Display test information
|
||||
run: |
|
||||
echo "🧪 Testing new workflow configurations"
|
||||
echo "Branch: ${{ github.ref_name }}"
|
||||
echo "Event: ${{ github.event_name }}"
|
||||
echo "Test type: ${{ github.event.inputs.test_type || 'all' }}"
|
||||
echo ""
|
||||
echo "This is a safe test environment - no changes will affect production workflows"
|
||||
|
||||
test-basic-setup:
|
||||
name: Test Basic Setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test setup-and-build action
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Verify Node.js version
|
||||
run: |
|
||||
echo "Node.js version: $(node --version)"
|
||||
if [[ "$(node --version)" == *"20.18.0"* ]]; then
|
||||
echo "✅ Correct Node.js version"
|
||||
else
|
||||
echo "❌ Wrong Node.js version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify pnpm version
|
||||
run: |
|
||||
echo "pnpm version: $(pnpm --version)"
|
||||
if [[ "$(pnpm --version)" == *"9.14.4"* ]]; then
|
||||
echo "✅ Correct pnpm version"
|
||||
else
|
||||
echo "❌ Wrong pnpm version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test build process
|
||||
run: |
|
||||
echo "✅ Build completed successfully"
|
||||
|
||||
test-linting:
|
||||
name: Test Linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Test ESLint
|
||||
run: |
|
||||
echo "Testing ESLint configuration..."
|
||||
pnpm run lint --max-warnings 0 || echo "ESLint found issues (expected for testing)"
|
||||
|
||||
- name: Test TypeScript
|
||||
run: |
|
||||
echo "Testing TypeScript compilation..."
|
||||
pnpm run typecheck
|
||||
|
||||
test-caching:
|
||||
name: Test Caching Strategy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Test TypeScript cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.tsbuildinfo
|
||||
node_modules/.cache
|
||||
key: test-${{ runner.os }}-typescript-${{ hashFiles('**/tsconfig.json', 'app/**/*.ts', 'app/**/*.tsx') }}
|
||||
restore-keys: |
|
||||
test-${{ runner.os }}-typescript-
|
||||
|
||||
- name: Test ESLint cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/.cache/eslint
|
||||
key: test-${{ runner.os }}-eslint-${{ hashFiles('.eslintrc*', 'app/**/*.ts', 'app/**/*.tsx') }}
|
||||
restore-keys: |
|
||||
test-${{ runner.os }}-eslint-
|
||||
|
||||
- name: Verify caching works
|
||||
run: |
|
||||
echo "✅ Caching configuration tested"
|
||||
|
||||
test-security-tools:
|
||||
name: Test Security Tools
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.test_type == 'all' || github.event.inputs.test_type == 'security-only'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.18.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.14.4'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test dependency audit (non-blocking)
|
||||
run: |
|
||||
echo "Testing pnpm audit..."
|
||||
pnpm audit --audit-level moderate || echo "Audit found issues (this is for testing)"
|
||||
|
||||
- name: Test Trivy installation
|
||||
run: |
|
||||
echo "Testing Trivy secrets scanner..."
|
||||
docker run --rm -v ${{ github.workspace }}:/workspace aquasecurity/trivy:latest fs /workspace --exit-code 0 --no-progress --format table --scanners secret || echo "Trivy test completed"
|
||||
|
||||
test-quality-checks:
|
||||
name: Test Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.test_type == 'all' || github.event.inputs.test_type == 'quality-only'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup and Build
|
||||
uses: ./.github/actions/setup-and-build
|
||||
|
||||
- name: Test bundle size analysis
|
||||
run: |
|
||||
echo "Testing bundle size analysis..."
|
||||
ls -la build/client/ || echo "Build directory structure checked"
|
||||
|
||||
- name: Test dependency checks
|
||||
run: |
|
||||
echo "Testing depcheck..."
|
||||
npx depcheck --config .depcheckrc.json || echo "Depcheck completed"
|
||||
|
||||
- name: Test package.json formatting
|
||||
run: |
|
||||
echo "Testing package.json sorting..."
|
||||
npx sort-package-json package.json --check || echo "Package.json check completed"
|
||||
|
||||
validate-docker-config:
|
||||
name: Validate Docker Configuration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Test Docker build (without push)
|
||||
run: |
|
||||
echo "Testing Docker build configuration..."
|
||||
docker build --target bolt-ai-production . --no-cache --progress=plain
|
||||
echo "✅ Docker build test completed"
|
||||
|
||||
test-results-summary:
|
||||
name: Test Results Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [workflow-test-info, test-basic-setup, test-linting, test-caching, test-security-tools, test-quality-checks, validate-docker-config]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all test results
|
||||
run: |
|
||||
echo "🧪 Workflow Testing Results Summary"
|
||||
echo "=================================="
|
||||
|
||||
if [[ "${{ needs.test-basic-setup.result }}" == "success" ]]; then
|
||||
echo "✅ Basic Setup: PASSED"
|
||||
else
|
||||
echo "❌ Basic Setup: FAILED"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.test-linting.result }}" == "success" ]]; then
|
||||
echo "✅ Linting Tests: PASSED"
|
||||
else
|
||||
echo "❌ Linting Tests: FAILED"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.test-caching.result }}" == "success" ]]; then
|
||||
echo "✅ Caching Tests: PASSED"
|
||||
else
|
||||
echo "❌ Caching Tests: FAILED"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.test-security-tools.result }}" == "success" ]]; then
|
||||
echo "✅ Security Tools: PASSED"
|
||||
else
|
||||
echo "❌ Security Tools: FAILED"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.test-quality-checks.result }}" == "success" ]]; then
|
||||
echo "✅ Quality Checks: PASSED"
|
||||
else
|
||||
echo "❌ Quality Checks: FAILED"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.validate-docker-config.result }}" == "success" ]]; then
|
||||
echo "✅ Docker Config: PASSED"
|
||||
else
|
||||
echo "❌ Docker Config: FAILED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Review any failures above"
|
||||
echo "2. Fix issues in workflow configurations"
|
||||
echo "3. Re-test until all checks pass"
|
||||
echo "4. Create PR to merge workflow improvements"
|
||||
4
.github/workflows/update-stable.yml
vendored
4
.github/workflows/update-stable.yml
vendored
@@ -26,12 +26,12 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.18.0'
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: '9.14.4'
|
||||
version: latest
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": ["http://localhost:5173/"],
|
||||
"startServerCommand": "pnpm run start",
|
||||
"numberOfRuns": 3
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["warn", {"minScore": 0.8}],
|
||||
"categories:accessibility": ["warn", {"minScore": 0.9}],
|
||||
"categories:best-practices": ["warn", {"minScore": 0.8}],
|
||||
"categories:seo": ["warn", {"minScore": 0.8}]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
Dockerfile
142
Dockerfile
@@ -1,103 +1,95 @@
|
||||
# ---- build stage ----
|
||||
FROM node:22-bookworm-slim AS build
|
||||
ARG BASE=node:20.18.0
|
||||
FROM ${BASE} AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# CI-friendly env
|
||||
ENV HUSKY=0
|
||||
ENV CI=true
|
||||
# Install dependencies (this step is cached as long as the dependencies don't change)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Use pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
#RUN npm install -g corepack@latest
|
||||
|
||||
# Ensure git is available for build and runtime scripts
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
#RUN corepack enable pnpm && pnpm install
|
||||
RUN npm install -g pnpm && pnpm install --prod=false
|
||||
|
||||
# Accept (optional) build-time public URL for Remix/Vite (Coolify can pass it)
|
||||
ARG VITE_PUBLIC_APP_URL
|
||||
ENV VITE_PUBLIC_APP_URL=${VITE_PUBLIC_APP_URL}
|
||||
|
||||
# Install deps efficiently
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm fetch
|
||||
|
||||
# Copy source and build
|
||||
# Copy the rest of your app's source code
|
||||
COPY . .
|
||||
# install with dev deps (needed to build)
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
|
||||
# Build the Remix app (SSR + client)
|
||||
RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm run build
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 5173
|
||||
|
||||
# ---- production dependencies stage ----
|
||||
FROM build AS prod-deps
|
||||
# Production image
|
||||
FROM base AS bolt-ai-production
|
||||
|
||||
# Keep only production deps for runtime
|
||||
RUN pnpm prune --prod --ignore-scripts
|
||||
|
||||
|
||||
# ---- production stage ----
|
||||
FROM prod-deps AS bolt-ai-production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5173
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Non-sensitive build arguments
|
||||
# Define environment variables with default values or let them be overridden
|
||||
ARG GROQ_API_KEY
|
||||
ARG HuggingFace_API_KEY
|
||||
ARG OPENAI_API_KEY
|
||||
ARG ANTHROPIC_API_KEY
|
||||
ARG OPEN_ROUTER_API_KEY
|
||||
ARG GOOGLE_GENERATIVE_AI_API_KEY
|
||||
ARG OLLAMA_API_BASE_URL
|
||||
ARG XAI_API_KEY
|
||||
ARG TOGETHER_API_KEY
|
||||
ARG TOGETHER_API_BASE_URL
|
||||
ARG AWS_BEDROCK_CONFIG
|
||||
ARG VITE_LOG_LEVEL=debug
|
||||
ARG DEFAULT_NUM_CTX
|
||||
|
||||
# Set non-sensitive environment variables
|
||||
ENV WRANGLER_SEND_METRICS=false \
|
||||
GROQ_API_KEY=${GROQ_API_KEY} \
|
||||
HuggingFace_KEY=${HuggingFace_API_KEY} \
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
|
||||
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
|
||||
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
|
||||
XAI_API_KEY=${XAI_API_KEY} \
|
||||
TOGETHER_API_KEY=${TOGETHER_API_KEY} \
|
||||
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
|
||||
AWS_BEDROCK_CONFIG=${AWS_BEDROCK_CONFIG} \
|
||||
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
|
||||
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \
|
||||
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}\
|
||||
RUNNING_IN_DOCKER=true
|
||||
|
||||
# Note: API keys should be provided at runtime via docker run -e or docker-compose
|
||||
# Example: docker run -e OPENAI_API_KEY=your_key_here ...
|
||||
|
||||
# Install curl for healthchecks and copy bindings script
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy built files and scripts
|
||||
COPY --from=prod-deps /app/build /app/build
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=prod-deps /app/package.json /app/package.json
|
||||
COPY --from=prod-deps /app/bindings.sh /app/bindings.sh
|
||||
|
||||
# Pre-configure wrangler to disable metrics
|
||||
RUN mkdir -p /root/.config/.wrangler && \
|
||||
echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json
|
||||
|
||||
# Make bindings script executable
|
||||
RUN chmod +x /app/bindings.sh
|
||||
RUN pnpm run build
|
||||
|
||||
EXPOSE 5173
|
||||
CMD [ "pnpm", "run", "dockerstart"]
|
||||
|
||||
# Healthcheck for deployment platforms
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \
|
||||
CMD curl -fsS http://localhost:5173/ || exit 1
|
||||
# Development image
|
||||
FROM base AS bolt-ai-development
|
||||
|
||||
# Start using dockerstart script with Wrangler
|
||||
CMD ["pnpm", "run", "dockerstart"]
|
||||
|
||||
|
||||
# ---- development stage ----
|
||||
FROM build AS development
|
||||
|
||||
# Non-sensitive development arguments
|
||||
# Define the same environment variables for development
|
||||
ARG GROQ_API_KEY
|
||||
ARG HuggingFace
|
||||
ARG OPENAI_API_KEY
|
||||
ARG ANTHROPIC_API_KEY
|
||||
ARG OPEN_ROUTER_API_KEY
|
||||
ARG GOOGLE_GENERATIVE_AI_API_KEY
|
||||
ARG OLLAMA_API_BASE_URL
|
||||
ARG XAI_API_KEY
|
||||
ARG TOGETHER_API_KEY
|
||||
ARG TOGETHER_API_BASE_URL
|
||||
ARG VITE_LOG_LEVEL=debug
|
||||
ARG DEFAULT_NUM_CTX
|
||||
|
||||
# Set non-sensitive environment variables for development
|
||||
ENV VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
|
||||
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \
|
||||
ENV GROQ_API_KEY=${GROQ_API_KEY} \
|
||||
HuggingFace_API_KEY=${HuggingFace_API_KEY} \
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
|
||||
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY} \
|
||||
OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \
|
||||
XAI_API_KEY=${XAI_API_KEY} \
|
||||
TOGETHER_API_KEY=${TOGETHER_API_KEY} \
|
||||
TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \
|
||||
AWS_BEDROCK_CONFIG=${AWS_BEDROCK_CONFIG} \
|
||||
VITE_LOG_LEVEL=${VITE_LOG_LEVEL} \
|
||||
DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX}\
|
||||
RUNNING_IN_DOCKER=true
|
||||
|
||||
# Note: API keys should be provided at runtime via docker run -e or docker-compose
|
||||
# Example: docker run -e OPENAI_API_KEY=your_key_here ...
|
||||
|
||||
RUN mkdir -p /app/run
|
||||
CMD ["pnpm", "run", "dev", "--host"]
|
||||
RUN mkdir -p ${WORKDIR}/run
|
||||
CMD pnpm run dev --host
|
||||
|
||||
344
README.md
344
README.md
@@ -2,10 +2,10 @@
|
||||
|
||||
[](https://bolt.diy)
|
||||
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, Groq, Cohere, Together, Perplexity, Moonshot (Kimi), Hyperbolic, GitHub Models, Amazon Bedrock, and OpenAI-like providers - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new, which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
|
||||
|
||||
-----
|
||||
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more official installation instructions and additional information.
|
||||
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
|
||||
|
||||
-----
|
||||
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
|
||||
@@ -17,13 +17,10 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
|
||||
## Table of Contents
|
||||
|
||||
- [Join the Community](#join-the-community)
|
||||
- [Recent Major Additions](#recent-major-additions)
|
||||
- [Requested Additions](#requested-additions)
|
||||
- [Features](#features)
|
||||
- [Setup](#setup)
|
||||
- [Quick Installation](#quick-installation)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [Configuring API Keys and Providers](#configuring-api-keys-and-providers)
|
||||
- [Setup Using Git (For Developers only)](#setup-using-git-for-developers-only)
|
||||
- [Run the Application](#run-the-application)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [Contributing](#contributing)
|
||||
- [Roadmap](#roadmap)
|
||||
@@ -41,52 +38,73 @@ you to understand where the current areas of focus are.
|
||||
If you want to know what we are working on, what we are planning to work on, or if you want to contribute to the
|
||||
project, please check the [project management guide](./PROJECT.md) to get started easily.
|
||||
|
||||
## Recent Major Additions
|
||||
## Requested Additions
|
||||
|
||||
### ✅ Completed Features
|
||||
- **19+ AI Provider Integrations** - OpenAI, Anthropic, Google, Groq, xAI, DeepSeek, Mistral, Cohere, Together, Perplexity, HuggingFace, Ollama, LM Studio, OpenRouter, Moonshot, Hyperbolic, GitHub Models, Amazon Bedrock, OpenAI-like
|
||||
- **Electron Desktop App** - Native desktop experience with full functionality
|
||||
- **Advanced Deployment Options** - Netlify, Vercel, and GitHub Pages deployment
|
||||
- **Supabase Integration** - Database management and query capabilities
|
||||
- **Data Visualization & Analysis** - Charts, graphs, and data analysis tools
|
||||
- **MCP (Model Context Protocol)** - Enhanced AI tool integration
|
||||
- **Search Functionality** - Codebase search and navigation
|
||||
- **File Locking System** - Prevents conflicts during AI code generation
|
||||
- **Diff View** - Visual representation of AI-made changes
|
||||
- **Git Integration** - Clone, import, and deployment capabilities
|
||||
- **Expo App Creation** - React Native development support
|
||||
- **Voice Prompting** - Audio input for prompts
|
||||
- **Bulk Chat Operations** - Delete multiple chats at once
|
||||
- **Project Snapshot Restoration** - Restore projects from snapshots on reload
|
||||
|
||||
### 🔄 In Progress / Planned
|
||||
- **File Locking & Diff Improvements** - Enhanced conflict prevention
|
||||
- **Backend Agent Architecture** - Move from single model calls to agent-based system
|
||||
- **LLM Prompt Optimization** - Better performance for smaller models
|
||||
- **Project Planning Documentation** - LLM-generated project plans in markdown
|
||||
- **VSCode Integration** - Git-like confirmations and workflows
|
||||
- **Document Upload for Knowledge** - Reference materials and coding style guides
|
||||
- **Additional Provider Integrations** - Azure OpenAI, Vertex AI, Granite
|
||||
- ✅ OpenRouter Integration (@coleam00)
|
||||
- ✅ Gemini Integration (@jonathands)
|
||||
- ✅ Autogenerate Ollama models from what is downloaded (@yunatamos)
|
||||
- ✅ Filter models by provider (@jasonm23)
|
||||
- ✅ Download project as ZIP (@fabwaseem)
|
||||
- ✅ Improvements to the main bolt.new prompt in `app\lib\.server\llm\prompts.ts` (@kofi-bhr)
|
||||
- ✅ DeepSeek API Integration (@zenith110)
|
||||
- ✅ Mistral API Integration (@ArulGandhi)
|
||||
- ✅ "Open AI Like" API Integration (@ZerxZ)
|
||||
- ✅ Ability to sync files (one way sync) to local folder (@muzafferkadir)
|
||||
- ✅ Containerize the application with Docker for easy installation (@aaronbolton)
|
||||
- ✅ Publish projects directly to GitHub (@goncaloalves)
|
||||
- ✅ Ability to enter API keys in the UI (@ali00209)
|
||||
- ✅ xAI Grok Beta Integration (@milutinke)
|
||||
- ✅ LM Studio Integration (@karrot0)
|
||||
- ✅ HuggingFace Integration (@ahsan3219)
|
||||
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
|
||||
- ✅ Streaming of code output (@thecodacus)
|
||||
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
|
||||
- ✅ Chat history backup and restore functionality (@sidbetatester)
|
||||
- ✅ Cohere Integration (@hasanraiyan)
|
||||
- ✅ Dynamic model max token length (@hasanraiyan)
|
||||
- ✅ Better prompt enhancing (@SujalXplores)
|
||||
- ✅ Prompt caching (@SujalXplores)
|
||||
- ✅ Load local projects into the app (@wonderwhy-er)
|
||||
- ✅ Together Integration (@mouimet-infinisoft)
|
||||
- ✅ Mobile friendly (@qwikode)
|
||||
- ✅ Better prompt enhancing (@SujalXplores)
|
||||
- ✅ Attach images to prompts (@atrokhym)(@stijnus)
|
||||
- ✅ Added Git Clone button (@thecodacus)
|
||||
- ✅ Git Import from url (@thecodacus)
|
||||
- ✅ PromptLibrary to have different variations of prompts for different use cases (@thecodacus)
|
||||
- ✅ Detect package.json and commands to auto install & run preview for folder and git import (@wonderwhy-er)
|
||||
- ✅ Selection tool to target changes visually (@emcconnell)
|
||||
- ✅ Detect terminal Errors and ask bolt to fix it (@thecodacus)
|
||||
- ✅ Detect preview Errors and ask bolt to fix it (@wonderwhy-er)
|
||||
- ✅ Add Starter Template Options (@thecodacus)
|
||||
- ✅ Perplexity Integration (@meetpateltech)
|
||||
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
||||
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
|
||||
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
|
||||
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
||||
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||
- ✅ Deploy directly to Netlify (@xKevIsDev)
|
||||
- ⬜ Supabase Integration
|
||||
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
||||
- ⬜ VSCode Integration with git-like confirmations
|
||||
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
||||
- ⬜ Voice prompting
|
||||
- ⬜ Azure Open AI API Integration
|
||||
- ⬜ Vertex AI Integration
|
||||
- ⬜ Granite Integration
|
||||
- ✅ Popout Window for Web Container(@stijnus)
|
||||
- ✅ Ability to change Popout window size (@stijnus)
|
||||
|
||||
## Features
|
||||
|
||||
- **AI-powered full-stack web development** for **NodeJS based applications** directly in your browser.
|
||||
- **Support for 19+ LLMs** with an extensible architecture to integrate additional models.
|
||||
- **Support for multiple LLMs** with an extensible architecture to integrate additional models.
|
||||
- **Attach images to prompts** for better contextual understanding.
|
||||
- **Integrated terminal** to view output of LLM-run commands.
|
||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
||||
- **Download projects as ZIP** for easy portability and sync to a folder on the host.
|
||||
- **Download projects as ZIP** for easy portability Sync to a folder on the host.
|
||||
- **Integration-ready Docker support** for a hassle-free setup.
|
||||
- **Deploy directly** to **Netlify**, **Vercel**, or **GitHub Pages**.
|
||||
- **Electron desktop app** for native desktop experience.
|
||||
- **Data visualization and analysis** with integrated charts and graphs.
|
||||
- **Git integration** with clone, import, and deployment capabilities.
|
||||
- **MCP (Model Context Protocol)** support for enhanced AI tool integration.
|
||||
- **Search functionality** to search through your codebase.
|
||||
- **File locking system** to prevent conflicts during AI code generation.
|
||||
- **Diff view** to see changes made by the AI.
|
||||
- **Supabase integration** for database management and queries.
|
||||
- **Expo app creation** for React Native development.
|
||||
- **Deploy** directly to **Netlify**
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -94,20 +112,17 @@ If you're new to installing software from GitHub, don't worry! If you encounter
|
||||
|
||||
Let's get you up and running with the stable version of Bolt.DIY!
|
||||
|
||||
## Quick Installation
|
||||
## Quick Download
|
||||
|
||||
[](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go to the latest release version!
|
||||
[](https://github.com/stackblitz-labs/bolt.diy/releases/latest) ← Click here to go the the latest release version!
|
||||
|
||||
- Download the binary for your platform (available for Windows, macOS, and Linux)
|
||||
- **Note**: For macOS, if you get the error "This app is damaged", run:
|
||||
```bash
|
||||
xattr -cr /path/to/Bolt.app
|
||||
```
|
||||
- Next **click source.zip**
|
||||
|
||||
## Manual installation
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, you'll need to install two important pieces of software:
|
||||
|
||||
### Option 1: Node.js
|
||||
### Install Node.js
|
||||
|
||||
Node.js is required to run the application.
|
||||
|
||||
@@ -154,205 +169,61 @@ You have two options for running Bolt.DIY: directly on your machine or using Doc
|
||||
|
||||
### Option 2: Using Docker
|
||||
|
||||
This option requires Docker and is great when you want an isolated environment or to mirror the production image.
|
||||
This option requires some familiarity with Docker but provides a more isolated environment.
|
||||
|
||||
#### Additional Prerequisite
|
||||
|
||||
- Install Docker: [Download Docker](https://www.docker.com/)
|
||||
|
||||
#### Steps
|
||||
#### Steps:
|
||||
|
||||
1. **Prepare Environment Variables**
|
||||
|
||||
Copy the provided examples and add your provider keys:
|
||||
1. **Build the Docker Image**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp .env.example .env.local
|
||||
# Using npm script:
|
||||
npm run dockerbuild
|
||||
|
||||
# OR using direct Docker command:
|
||||
docker build . --target bolt-ai-development
|
||||
```
|
||||
|
||||
The runtime scripts inside the container source `.env` and `.env.local`, so keep any API keys you need in one of those files.
|
||||
|
||||
2. **Build an Image**
|
||||
|
||||
2. **Run the Container**:
|
||||
```bash
|
||||
# Development image (bind-mounts your local source when run)
|
||||
pnpm run dockerbuild
|
||||
# ≈ docker build -t bolt-ai:development -t bolt-ai:latest --target development .
|
||||
|
||||
# Production image (self-contained build artifacts)
|
||||
pnpm run dockerbuild:prod
|
||||
# ≈ docker build -t bolt-ai:production -t bolt-ai:latest --target bolt-ai-production .
|
||||
```
|
||||
|
||||
3. **Run the Container**
|
||||
|
||||
```bash
|
||||
# Development workflow with hot reload
|
||||
docker compose --profile development up
|
||||
|
||||
# Production-style container using composed services
|
||||
docker compose --profile production up
|
||||
|
||||
# One-off production container (exposes the app on port 5173)
|
||||
docker run --rm -p 5173:5173 --env-file .env.local bolt-ai:latest
|
||||
```
|
||||
|
||||
When the container starts it runs `pnpm run dockerstart`, which in turn executes `bindings.sh` to pass Cloudflare bindings through Wrangler. You can override this command in `docker-compose.yaml` if you need a different startup routine.
|
||||
|
||||
### Option 3: Desktop Application (Electron)
|
||||
|
||||
For users who prefer a native desktop experience, bolt.diy is also available as an Electron desktop application:
|
||||
|
||||
1. **Download the Desktop App**:
|
||||
- Visit the [latest release](https://github.com/stackblitz-labs/bolt.diy/releases/latest)
|
||||
- Download the appropriate binary for your operating system
|
||||
- For macOS: Extract and run the `.dmg` file
|
||||
- For Windows: Run the `.exe` installer
|
||||
- For Linux: Extract and run the AppImage or install the `.deb` package
|
||||
|
||||
2. **Alternative**: Build from Source:
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the Electron app
|
||||
pnpm electron:build:dist # For all platforms
|
||||
# OR platform-specific:
|
||||
pnpm electron:build:mac # macOS
|
||||
pnpm electron:build:win # Windows
|
||||
pnpm electron:build:linux # Linux
|
||||
```
|
||||
|
||||
The desktop app provides the same full functionality as the web version with additional native features.
|
||||
|
||||
## Configuring API Keys and Providers
|
||||
|
||||
Bolt.diy features a modern, intuitive settings interface for managing AI providers and API keys. The settings are organized into dedicated panels for easy navigation and configuration.
|
||||
### Adding Your API Keys
|
||||
|
||||
### Accessing Provider Settings
|
||||
Setting up your API keys in Bolt.DIY is straightforward:
|
||||
|
||||
1. **Open Settings**: Click the settings icon (⚙️) in the sidebar to access the settings panel
|
||||
2. **Navigate to Providers**: Select the "Providers" tab from the settings menu
|
||||
3. **Choose Provider Type**: Switch between "Cloud Providers" and "Local Providers" tabs
|
||||
1. Open the home page (main interface)
|
||||
2. Select your desired provider from the dropdown menu
|
||||
3. Click the pencil (edit) icon
|
||||
4. Enter your API key in the secure input field
|
||||
|
||||
### Cloud Providers Configuration
|
||||

|
||||
|
||||
The Cloud Providers tab displays all cloud-based AI services in an organized card layout:
|
||||
### Configuring Custom Base URLs
|
||||
|
||||
#### Adding API Keys
|
||||
1. **Select Provider**: Browse the grid of available cloud providers (OpenAI, Anthropic, Google, etc.)
|
||||
2. **Toggle Provider**: Use the switch to enable/disable each provider
|
||||
3. **Set API Key**:
|
||||
- Click the provider card to expand its configuration
|
||||
- Click on the "API Key" field to enter edit mode
|
||||
- Paste your API key and press Enter to save
|
||||
- The interface shows real-time validation with green checkmarks for valid keys
|
||||
For providers that support custom base URLs (such as Ollama or LM Studio), follow these steps:
|
||||
|
||||
#### Advanced Features
|
||||
- **Bulk Toggle**: Use "Enable All Cloud" to toggle all cloud providers at once
|
||||
- **Visual Status**: Green checkmarks indicate properly configured providers
|
||||
- **Provider Icons**: Each provider has a distinctive icon for easy identification
|
||||
- **Descriptions**: Helpful descriptions explain each provider's capabilities
|
||||
1. Click the settings icon in the sidebar to open the settings menu
|
||||

|
||||
|
||||
### Local Providers Configuration
|
||||
2. Navigate to the "Providers" tab
|
||||
3. Search for your provider using the search bar
|
||||
4. Enter your custom base URL in the designated field
|
||||

|
||||
|
||||
The Local Providers tab manages local AI installations and custom endpoints:
|
||||
> **Note**: Custom base URLs are particularly useful when running local instances of AI models or using custom API endpoints.
|
||||
|
||||
#### Ollama Configuration
|
||||
1. **Enable Ollama**: Toggle the Ollama provider switch
|
||||
2. **Configure Endpoint**: Set the API endpoint (defaults to `http://127.0.0.1:11434`)
|
||||
3. **Model Management**:
|
||||
- View all installed models with size and parameter information
|
||||
- Update models to latest versions with one click
|
||||
- Delete unused models
|
||||
- Install new models by entering model names
|
||||
### Supported Providers
|
||||
|
||||
#### Other Local Providers
|
||||
- **LM Studio**: Configure custom base URLs for LM Studio endpoints
|
||||
- **OpenAI-like**: Connect to any OpenAI-compatible API endpoint
|
||||
- **Auto-detection**: The system automatically detects environment variables for base URLs
|
||||
|
||||
### Environment Variables vs UI Configuration
|
||||
|
||||
Bolt.diy supports both methods for maximum flexibility:
|
||||
|
||||
#### Environment Variables (Recommended for Production)
|
||||
Set API keys and base URLs in your `.env.local` file:
|
||||
```bash
|
||||
# API Keys
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
|
||||
# Custom Base URLs
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
LMSTUDIO_BASE_URL=http://127.0.0.1:1234
|
||||
```
|
||||
|
||||
#### UI-Based Configuration
|
||||
- **Real-time Updates**: Changes take effect immediately
|
||||
- **Secure Storage**: API keys are stored securely in browser cookies
|
||||
- **Visual Feedback**: Clear indicators show configuration status
|
||||
- **Easy Management**: Edit, view, and manage keys through the interface
|
||||
|
||||
### Provider-Specific Features
|
||||
|
||||
#### OpenRouter
|
||||
- **Free Models Filter**: Toggle to show only free models when browsing
|
||||
- **Pricing Information**: View input/output costs for each model
|
||||
- **Model Search**: Fuzzy search through all available models
|
||||
|
||||
#### Ollama
|
||||
- **Model Installer**: Built-in interface to install new models
|
||||
- **Progress Tracking**: Real-time download progress for model updates
|
||||
- **Model Details**: View model size, parameters, and quantization levels
|
||||
- **Auto-refresh**: Automatically detects newly installed models
|
||||
|
||||
#### Search & Navigation
|
||||
- **Fuzzy Search**: Type-ahead search across all providers and models
|
||||
- **Keyboard Navigation**: Use arrow keys and Enter to navigate quickly
|
||||
- **Clear Search**: Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to clear search
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Common Issues
|
||||
- **API Key Not Recognized**: Ensure you're using the correct API key format for each provider
|
||||
- **Base URL Issues**: Verify the endpoint URL is correct and accessible
|
||||
- **Model Not Loading**: Check that the provider is enabled and properly configured
|
||||
- **Environment Variables Not Working**: Restart the application after adding new environment variables
|
||||
|
||||
#### Status Indicators
|
||||
- 🟢 **Green Checkmark**: Provider properly configured and ready to use
|
||||
- 🔴 **Red X**: Configuration missing or invalid
|
||||
- 🟡 **Yellow Indicator**: Provider enabled but may need additional setup
|
||||
- 🔵 **Blue Pencil**: Click to edit configuration
|
||||
|
||||
### Supported Providers Overview
|
||||
|
||||
#### Cloud Providers
|
||||
- **OpenAI** - GPT-4, GPT-3.5, and other OpenAI models
|
||||
- **Anthropic** - Claude 3.5 Sonnet, Claude 3 Opus, and other Claude models
|
||||
- **Google (Gemini)** - Gemini 1.5 Pro, Gemini 1.5 Flash, and other Gemini models
|
||||
- **Groq** - Fast inference with Llama, Mixtral, and other models
|
||||
- **xAI** - Grok models including Grok-2 and Grok-2 Vision
|
||||
- **DeepSeek** - DeepSeek Coder and other DeepSeek models
|
||||
- **Mistral** - Mixtral, Mistral 7B, and other Mistral models
|
||||
- **Cohere** - Command R, Command R+, and other Cohere models
|
||||
- **Together AI** - Various open-source models
|
||||
- **Perplexity** - Sonar models for search and reasoning
|
||||
- **HuggingFace** - Access to HuggingFace model hub
|
||||
- **OpenRouter** - Unified API for multiple model providers
|
||||
- **Moonshot (Kimi)** - Kimi AI models
|
||||
- **Hyperbolic** - High-performance model inference
|
||||
- **GitHub Models** - Models available through GitHub
|
||||
- **Amazon Bedrock** - AWS managed AI models
|
||||
|
||||
#### Local Providers
|
||||
- **Ollama** - Run open-source models locally with advanced model management
|
||||
- **LM Studio** - Local model inference with LM Studio
|
||||
- **OpenAI-like** - Connect to any OpenAI-compatible API endpoint
|
||||
|
||||
> **💡 Pro Tip**: Start with OpenAI or Anthropic for the best results, then explore other providers based on your specific needs and budget considerations.
|
||||
- Ollama
|
||||
- LM Studio
|
||||
- OpenAILike
|
||||
|
||||
## Setup Using Git (For Developers only)
|
||||
|
||||
@@ -401,7 +272,7 @@ This method is recommended for developers who want to:
|
||||
Hint: Be aware that this can have beta-features and more likely got bugs than the stable release
|
||||
|
||||
>**Open the WebUI to test (Default: http://localhost:5173)**
|
||||
> - Beginners:
|
||||
> - Beginngers:
|
||||
> - Try to use a sophisticated Provider/Model like Anthropic with Claude Sonnet 3.x Models to get best results
|
||||
> - Explanation: The System Prompt currently implemented in bolt.diy cant cover the best performance for all providers and models out there. So it works better with some models, then other, even if the models itself are perfect for >programming
|
||||
> - Future: Planned is a Plugin/Extentions-Library so there can be different System Prompts for different Models, which will help to get better results
|
||||
@@ -470,25 +341,7 @@ Remember to always commit your local changes or stash them before pulling update
|
||||
- **`pnpm run typecheck`**: Runs TypeScript type checking.
|
||||
- **`pnpm run typegen`**: Generates TypeScript types using Wrangler.
|
||||
- **`pnpm run deploy`**: Deploys the project to Cloudflare Pages.
|
||||
- **`pnpm run lint`**: Runs ESLint to check for code issues.
|
||||
- **`pnpm run lint:fix`**: Automatically fixes linting issues.
|
||||
- **`pnpm run clean`**: Cleans build artifacts and cache.
|
||||
- **`pnpm run prepare`**: Sets up husky for git hooks.
|
||||
- **Docker Scripts**:
|
||||
- **`pnpm run dockerbuild`**: Builds the Docker image for development.
|
||||
- **`pnpm run dockerbuild:prod`**: Builds the Docker image for production.
|
||||
- **`pnpm run dockerrun`**: Runs the Docker container.
|
||||
- **`pnpm run dockerstart`**: Starts the Docker container with proper bindings.
|
||||
- **Electron Scripts**:
|
||||
- **`pnpm electron:build:deps`**: Builds Electron main and preload scripts.
|
||||
- **`pnpm electron:build:main`**: Builds the Electron main process.
|
||||
- **`pnpm electron:build:preload`**: Builds the Electron preload script.
|
||||
- **`pnpm electron:build:renderer`**: Builds the Electron renderer.
|
||||
- **`pnpm electron:build:unpack`**: Creates an unpacked Electron build.
|
||||
- **`pnpm electron:build:mac`**: Builds for macOS.
|
||||
- **`pnpm electron:build:win`**: Builds for Windows.
|
||||
- **`pnpm electron:build:linux`**: Builds for Linux.
|
||||
- **`pnpm electron:build:dist`**: Builds for all platforms.
|
||||
|
||||
---
|
||||
|
||||
@@ -513,4 +366,3 @@ For answers to common questions, issues, and to see a list of recommended models
|
||||
**Who needs a commercial WebContainer API license?**
|
||||
|
||||
bolt.diy source code is distributed as MIT, but it uses WebContainers API that [requires licensing](https://webcontainers.io/enterprise) for production usage in a commercial, for-profit setting. (Prototypes or POCs do not require a commercial license.) If you're using the API to meet the needs of your customers, prospective customers, and/or employees, you need a license to ensure compliance with our Terms of Service. Usage of the API in violation of these terms may result in your access being revoked.
|
||||
# Test commit to trigger Security Analysis workflow
|
||||
|
||||
@@ -5,6 +5,12 @@ import { classNames } from '~/utils/classNames';
|
||||
import { profileStore } from '~/lib/stores/profile';
|
||||
import type { TabType, Profile } from './types';
|
||||
|
||||
const BetaLabel = () => (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
|
||||
BETA
|
||||
</span>
|
||||
);
|
||||
|
||||
interface AvatarDropdownProps {
|
||||
onSelectTab: (tab: TabType) => void;
|
||||
}
|
||||
@@ -30,7 +36,7 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
||||
<div className="i-ph:user w-6 h-6" />
|
||||
<div className="i-ph:question w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
@@ -66,7 +72,7 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
||||
<div className="i-ph:user w-6 h-6" />
|
||||
<span className="relative -top-0.5">?</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,12 +128,11 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
||||
'outline-none',
|
||||
'group',
|
||||
)}
|
||||
onClick={() =>
|
||||
window.open('https://github.com/stackblitz-labs/bolt.diy/issues/new?template=bug_report.yml', '_blank')
|
||||
}
|
||||
onClick={() => onSelectTab('task-manager')}
|
||||
>
|
||||
<div className="i-ph:bug w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Report Bug
|
||||
<div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Task Manager
|
||||
<BetaLabel />
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
@@ -140,33 +145,11 @@ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
||||
'outline-none',
|
||||
'group',
|
||||
)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { downloadDebugLog } = await import('~/utils/debugLogger');
|
||||
await downloadDebugLog();
|
||||
} catch (error) {
|
||||
console.error('Failed to download debug log:', error);
|
||||
}
|
||||
}}
|
||||
onClick={() => onSelectTab('service-status')}
|
||||
>
|
||||
<div className="i-ph:download w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Download Debug Log
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
)}
|
||||
onClick={() => window.open('https://stackblitz-labs.github.io/bolt.diy/', '_blank')}
|
||||
>
|
||||
<div className="i-ph:question w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Help & Documentation
|
||||
<div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
Service Status
|
||||
<BetaLabel />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Switch } from '@radix-ui/react-switch';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
|
||||
import { TabTile } from '~/components/@settings/shared/components/TabTile';
|
||||
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
||||
import { useFeatures } from '~/lib/hooks/useFeatures';
|
||||
import { useNotifications } from '~/lib/hooks/useNotifications';
|
||||
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
|
||||
import { tabConfigurationStore, resetTabConfiguration } from '~/lib/stores/settings';
|
||||
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
||||
import {
|
||||
tabConfigurationStore,
|
||||
developerModeStore,
|
||||
setDeveloperMode,
|
||||
resetTabConfiguration,
|
||||
} from '~/lib/stores/settings';
|
||||
import { profileStore } from '~/lib/stores/profile';
|
||||
import type { TabType, Profile } from './types';
|
||||
import { TAB_LABELS, DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants';
|
||||
import type { TabType, TabVisibilityConfig, Profile } from './types';
|
||||
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
|
||||
import { DialogTitle } from '~/components/ui/Dialog';
|
||||
import { AvatarDropdown } from './AvatarDropdown';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
@@ -20,23 +30,61 @@ import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
||||
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
||||
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
||||
import { DataTab } from '~/components/@settings/tabs/data/DataTab';
|
||||
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
||||
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
||||
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 UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
||||
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
||||
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
||||
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
|
||||
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
||||
import McpTab from '~/components/@settings/tabs/mcp/McpTab';
|
||||
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
||||
|
||||
interface ControlPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TabWithDevType extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface ExtendedTabConfig extends TabVisibilityConfig {
|
||||
isExtraDevTab?: boolean;
|
||||
}
|
||||
|
||||
interface BaseTabConfig {
|
||||
id: TabType;
|
||||
visible: boolean;
|
||||
window: 'user' | 'developer';
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface AnimatedSwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
||||
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',
|
||||
'service-status': 'Monitor cloud LLM service status',
|
||||
connection: 'Check connection status and settings',
|
||||
debug: 'Debug tools and system information',
|
||||
'event-logs': 'View system events and logs',
|
||||
update: 'Check for updates and release notes',
|
||||
'task-manager': 'Monitor system resources and processes',
|
||||
'tab-management': 'Configure visible tabs and their order',
|
||||
};
|
||||
|
||||
// Beta status for experimental features
|
||||
const BETA_TABS = new Set<TabType>(['local-providers', 'mcp']);
|
||||
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
||||
|
||||
const BetaLabel = () => (
|
||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
||||
@@ -44,6 +92,66 @@ const BetaLabel = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={classNames(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full',
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
|
||||
'bg-gray-200 dark:bg-gray-700',
|
||||
'data-[state=checked]:bg-purple-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
|
||||
'cursor-pointer',
|
||||
'group',
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
className={classNames(
|
||||
'absolute left-[2px] top-[2px]',
|
||||
'inline-block h-5 w-5 rounded-full',
|
||||
'bg-white shadow-lg',
|
||||
'transition-shadow duration-300',
|
||||
'group-hover:shadow-md group-active:shadow-sm',
|
||||
'group-hover:scale-95 group-active:scale-90',
|
||||
)}
|
||||
initial={false}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
duration: 0.2,
|
||||
}}
|
||||
animate={{
|
||||
x: checked ? '1.25rem' : '0rem',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-white"
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: checked ? 1 : 0.8,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.span>
|
||||
<span className="sr-only">Toggle {label}</span>
|
||||
</Switch>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
||||
@@ -52,12 +160,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
|
||||
// Store values
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const developerMode = useStore(developerModeStore);
|
||||
const profile = useStore(profileStore) as Profile;
|
||||
|
||||
// Status hooks
|
||||
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
||||
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
|
||||
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
||||
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
||||
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
||||
|
||||
// Memoize the base tab configurations to avoid recalculation
|
||||
const baseTabConfig = useMemo(() => {
|
||||
@@ -75,6 +186,41 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
|
||||
const notificationsDisabled = profile?.preferences?.notifications === false;
|
||||
|
||||
// In developer mode, show ALL tabs without restrictions
|
||||
if (developerMode) {
|
||||
const seenTabs = new Set<TabType>();
|
||||
const devTabs: ExtendedTabConfig[] = [];
|
||||
|
||||
// Process tabs in order of priority: developer, user, default
|
||||
const processTab = (tab: BaseTabConfig) => {
|
||||
if (!seenTabs.has(tab.id)) {
|
||||
seenTabs.add(tab.id);
|
||||
devTabs.push({
|
||||
id: tab.id,
|
||||
visible: true,
|
||||
window: 'developer',
|
||||
order: tab.order || devTabs.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Process tabs in priority order
|
||||
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
||||
|
||||
// Add Tab Management tile
|
||||
devTabs.push({
|
||||
id: 'tab-management' as TabType,
|
||||
visible: true,
|
||||
window: 'developer',
|
||||
order: devTabs.length,
|
||||
isExtraDevTab: true,
|
||||
});
|
||||
|
||||
return devTabs.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// Optimize user mode tab filtering
|
||||
return tabConfiguration.userTabs
|
||||
.filter((tab) => {
|
||||
@@ -89,7 +235,33 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
return tab.visible && tab.window === 'user';
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
|
||||
}, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
|
||||
|
||||
// Optimize animation performance with layout animations
|
||||
const gridLayoutVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.6,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Reset to default view when modal opens/closes
|
||||
useEffect(() => {
|
||||
@@ -121,7 +293,21 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTabComponent = (tabId: TabType) => {
|
||||
const handleDeveloperModeChange = (checked: boolean) => {
|
||||
console.log('Developer mode changed:', checked);
|
||||
setDeveloperMode(checked);
|
||||
};
|
||||
|
||||
// Add effect to log developer mode changes
|
||||
useEffect(() => {
|
||||
console.log('Current developer mode:', developerMode);
|
||||
}, [developerMode]);
|
||||
|
||||
const getTabComponent = (tabId: TabType | 'tab-management') => {
|
||||
if (tabId === 'tab-management') {
|
||||
return <TabManagement />;
|
||||
}
|
||||
|
||||
switch (tabId) {
|
||||
case 'profile':
|
||||
return <ProfileTab />;
|
||||
@@ -137,21 +323,18 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
return <CloudProvidersTab />;
|
||||
case 'local-providers':
|
||||
return <LocalProvidersTab />;
|
||||
case 'github':
|
||||
return <GitHubTab />;
|
||||
case 'gitlab':
|
||||
return <GitLabTab />;
|
||||
case 'supabase':
|
||||
return <SupabaseTab />;
|
||||
case 'vercel':
|
||||
return <VercelTab />;
|
||||
case 'netlify':
|
||||
return <NetlifyTab />;
|
||||
case 'connection':
|
||||
return <ConnectionsTab />;
|
||||
case 'debug':
|
||||
return <DebugTab />;
|
||||
case 'event-logs':
|
||||
return <EventLogsTab />;
|
||||
case 'mcp':
|
||||
return <McpTab />;
|
||||
|
||||
case 'update':
|
||||
return <UpdateTab />;
|
||||
case 'task-manager':
|
||||
return <TaskManagerTab />;
|
||||
case 'service-status':
|
||||
return <ServiceStatusTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -159,16 +342,16 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
|
||||
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
||||
switch (tabId) {
|
||||
case 'update':
|
||||
return hasUpdate;
|
||||
case 'features':
|
||||
return hasNewFeatures;
|
||||
case 'notifications':
|
||||
return hasUnreadNotifications;
|
||||
case 'github':
|
||||
case 'gitlab':
|
||||
case 'supabase':
|
||||
case 'vercel':
|
||||
case 'netlify':
|
||||
case 'connection':
|
||||
return hasConnectionIssues;
|
||||
case 'debug':
|
||||
return hasActiveWarnings;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -176,20 +359,24 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
|
||||
const getStatusMessage = (tabId: TabType): string => {
|
||||
switch (tabId) {
|
||||
case 'update':
|
||||
return `New update available (v${currentVersion})`;
|
||||
case 'features':
|
||||
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
|
||||
case 'notifications':
|
||||
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
||||
case 'github':
|
||||
case 'gitlab':
|
||||
case 'supabase':
|
||||
case 'vercel':
|
||||
case 'netlify':
|
||||
case 'connection':
|
||||
return currentIssue === 'disconnected'
|
||||
? 'Connection lost'
|
||||
: currentIssue === 'high-latency'
|
||||
? 'High latency detected'
|
||||
: 'Connection issues detected';
|
||||
case 'debug': {
|
||||
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
||||
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
||||
|
||||
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -202,19 +389,21 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
|
||||
// Acknowledge notifications based on tab
|
||||
switch (tabId) {
|
||||
case 'update':
|
||||
acknowledgeUpdate();
|
||||
break;
|
||||
case 'features':
|
||||
acknowledgeAllFeatures();
|
||||
break;
|
||||
case 'notifications':
|
||||
markAllAsRead();
|
||||
break;
|
||||
case 'github':
|
||||
case 'gitlab':
|
||||
case 'supabase':
|
||||
case 'vercel':
|
||||
case 'netlify':
|
||||
case 'connection':
|
||||
acknowledgeIssue();
|
||||
break;
|
||||
case 'debug':
|
||||
acknowledgeAllIssues();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear loading state after a delay
|
||||
@@ -225,7 +414,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
|
||||
<RadixDialog.Overlay className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm transition-opacity duration-200" />
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
|
||||
<RadixDialog.Content
|
||||
aria-describedby={undefined}
|
||||
@@ -233,17 +430,19 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<div
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-[1200px] h-[90vh]',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
||||
'rounded-2xl shadow-2xl',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'flex flex-col overflow-hidden',
|
||||
'relative',
|
||||
'transform transition-all duration-200 ease-out',
|
||||
open ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-4',
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
||||
<BackgroundRays />
|
||||
@@ -255,7 +454,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
{(activeTab || showTabManagement) && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</button>
|
||||
@@ -266,8 +465,18 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
|
||||
<AnimatedSwitch
|
||||
id="developer-mode"
|
||||
checked={developerMode}
|
||||
onCheckedChange={handleDeveloperModeChange}
|
||||
label={developerMode ? 'Developer Mode' : 'User Mode'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Avatar and Dropdown */}
|
||||
<div className="pl-6">
|
||||
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
||||
<AvatarDropdown onSelectTab={handleTabClick} />
|
||||
</div>
|
||||
|
||||
@@ -295,48 +504,49 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
'touch-auto',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'p-6 transition-opacity duration-150',
|
||||
activeTab || showTabManagement ? 'opacity-100' : 'opacity-100',
|
||||
)}
|
||||
<motion.div
|
||||
key={activeTab || 'home'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-6"
|
||||
>
|
||||
{activeTab ? (
|
||||
{showTabManagement ? (
|
||||
<TabManagement />
|
||||
) : activeTab ? (
|
||||
getTabComponent(activeTab)
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
|
||||
{visibleTabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={classNames(
|
||||
'aspect-[1.5/1] transition-transform duration-100 ease-out',
|
||||
'hover:scale-[1.01]',
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: open ? 'fadeInUp 200ms ease-out forwards' : 'none',
|
||||
}}
|
||||
>
|
||||
<TabTile
|
||||
tab={tab}
|
||||
onClick={() => handleTabClick(tab.id as TabType)}
|
||||
isActive={activeTab === tab.id}
|
||||
hasUpdate={getTabUpdateStatus(tab.id)}
|
||||
statusMessage={getStatusMessage(tab.id)}
|
||||
description={TAB_DESCRIPTIONS[tab.id]}
|
||||
isLoading={loadingTab === tab.id}
|
||||
className="h-full relative"
|
||||
>
|
||||
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
||||
</TabTile>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
||||
variants={gridLayoutVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
||||
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
||||
<TabTile
|
||||
tab={tab}
|
||||
onClick={() => handleTabClick(tab.id as TabType)}
|
||||
isActive={activeTab === tab.id}
|
||||
hasUpdate={getTabUpdateStatus(tab.id)}
|
||||
statusMessage={getStatusMessage(tab.id)}
|
||||
description={TAB_DESCRIPTIONS[tab.id]}
|
||||
isLoading={loadingTab === tab.id}
|
||||
className="h-full relative"
|
||||
>
|
||||
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
||||
</TabTile>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</div>
|
||||
</RadixDialog.Portal>
|
||||
|
||||
88
app/components/@settings/core/constants.ts
Normal file
88
app/components/@settings/core/constants.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { TabType } from './types';
|
||||
|
||||
export const TAB_ICONS: Record<TabType, string> = {
|
||||
profile: 'i-ph:user-circle-fill',
|
||||
settings: 'i-ph:gear-six-fill',
|
||||
notifications: 'i-ph:bell-fill',
|
||||
features: 'i-ph:star-fill',
|
||||
data: 'i-ph:database-fill',
|
||||
'cloud-providers': 'i-ph:cloud-fill',
|
||||
'local-providers': 'i-ph:desktop-fill',
|
||||
'service-status': 'i-ph:activity-bold',
|
||||
connection: 'i-ph:wifi-high-fill',
|
||||
debug: 'i-ph:bug-fill',
|
||||
'event-logs': 'i-ph:list-bullets-fill',
|
||||
update: 'i-ph:arrow-clockwise-fill',
|
||||
'task-manager': 'i-ph:chart-line-fill',
|
||||
'tab-management': 'i-ph:squares-four-fill',
|
||||
};
|
||||
|
||||
export const TAB_LABELS: Record<TabType, string> = {
|
||||
profile: 'Profile',
|
||||
settings: 'Settings',
|
||||
notifications: 'Notifications',
|
||||
features: 'Features',
|
||||
data: 'Data Management',
|
||||
'cloud-providers': 'Cloud Providers',
|
||||
'local-providers': 'Local Providers',
|
||||
'service-status': 'Service Status',
|
||||
connection: 'Connection',
|
||||
debug: 'Debug',
|
||||
'event-logs': 'Event Logs',
|
||||
update: 'Updates',
|
||||
'task-manager': 'Task Manager',
|
||||
'tab-management': 'Tab Management',
|
||||
};
|
||||
|
||||
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
||||
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',
|
||||
'service-status': 'Monitor cloud LLM service status',
|
||||
connection: 'Check connection status and settings',
|
||||
debug: 'Debug tools and system information',
|
||||
'event-logs': 'View system events and logs',
|
||||
update: 'Check for updates and release notes',
|
||||
'task-manager': 'Monitor system resources and processes',
|
||||
'tab-management': 'Configure visible tabs and their order',
|
||||
};
|
||||
|
||||
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 },
|
||||
|
||||
// User Window Tabs (In dropdown, initially hidden)
|
||||
{ id: 'profile', visible: false, window: 'user' as const, order: 7 },
|
||||
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
|
||||
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
|
||||
{ id: 'service-status', visible: false, window: 'user' as const, order: 10 },
|
||||
|
||||
// User Window Tabs (Hidden, controlled by TaskManagerTab)
|
||||
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
|
||||
{ id: 'update', visible: false, window: 'user' as const, order: 12 },
|
||||
|
||||
// Developer Window Tabs (All visible by default)
|
||||
{ id: 'features', visible: true, window: 'developer' as const, order: 0 },
|
||||
{ id: 'data', visible: true, window: 'developer' as const, order: 1 },
|
||||
{ id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
|
||||
{ id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
|
||||
{ id: 'connection', visible: true, window: 'developer' as const, order: 4 },
|
||||
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
|
||||
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
|
||||
{ id: 'profile', visible: true, window: 'developer' as const, order: 7 },
|
||||
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
|
||||
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
|
||||
{ id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
|
||||
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
|
||||
{ id: 'update', visible: true, window: 'developer' as const, order: 12 },
|
||||
];
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { TabType } from './types';
|
||||
import { User, Settings, Bell, Star, Database, Cloud, Laptop, Github, Wrench, List } from 'lucide-react';
|
||||
|
||||
// GitLab icon component
|
||||
const GitLabIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Vercel icon component
|
||||
const VercelIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path fill="currentColor" d="M12 2L2 19.777h20L12 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Netlify icon component
|
||||
const NetlifyIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.934 8.519a1.044 1.044 0 0 1 .303-.23l2.349-1.045a.983.983 0 0 1 .905 0c.264.12.49.328.651.599l.518 1.065c.17.35.17.761 0 1.11l-.518 1.065a1.119 1.119 0 0 1-.651.599l-2.35 1.045a1.013 1.013 0 0 1-.904 0l-2.35-1.045a1.119 1.119 0 0 1-.651-.599L13.718 9.02a1.2 1.2 0 0 1 0-1.11l.518-1.065a1.119 1.119 0 0 1 .651-.599l2.35-1.045a.983.983 0 0 1 .697-.061zm-6.051 5.751a1.044 1.044 0 0 1 .303-.23l2.349-1.045a.983.983 0 0 1 .905 0c.264.12.49.328.651.599l.518 1.065c.17.35.17.761 0 1.11l-.518 1.065a1.119 1.119 0 0 1-.651.599l-2.35 1.045a1.013 1.013 0 0 1-.904 0l-2.35-1.045a1.119 1.119 0 0 1-.651-.599l-.518-1.065a1.2 1.2 0 0 1 0-1.11l.518-1.065a1.119 1.119 0 0 1 .651-.599l2.35-1.045a.983.983 0 0 1 .697-.061z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Supabase icon component
|
||||
const SupabaseIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21.362 9.354H12V.396a.396.396 0 0 0-.716-.233L2.203 12.424l-.401.562a1.04 1.04 0 0 0 .836 1.659H12V21.6a.396.396 0 0 0 .716.233l9.081-12.261.401-.562a1.04 1.04 0 0 0-.836-1.656z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TAB_ICONS: Record<TabType, React.ComponentType<{ className?: string }>> = {
|
||||
profile: User,
|
||||
settings: Settings,
|
||||
notifications: Bell,
|
||||
features: Star,
|
||||
data: Database,
|
||||
'cloud-providers': Cloud,
|
||||
'local-providers': Laptop,
|
||||
github: Github,
|
||||
gitlab: () => <GitLabIcon />,
|
||||
netlify: () => <NetlifyIcon />,
|
||||
vercel: () => <VercelIcon />,
|
||||
supabase: () => <SupabaseIcon />,
|
||||
'event-logs': List,
|
||||
mcp: Wrench,
|
||||
};
|
||||
|
||||
export const TAB_LABELS: Record<TabType, string> = {
|
||||
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<TabType, string> = {
|
||||
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)
|
||||
];
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -11,13 +10,13 @@ export type TabType =
|
||||
| 'data'
|
||||
| 'cloud-providers'
|
||||
| 'local-providers'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'netlify'
|
||||
| 'vercel'
|
||||
| 'supabase'
|
||||
| 'service-status'
|
||||
| 'connection'
|
||||
| 'debug'
|
||||
| 'event-logs'
|
||||
| 'mcp';
|
||||
| 'update'
|
||||
| 'task-manager'
|
||||
| 'tab-management';
|
||||
|
||||
export type WindowType = 'user' | 'developer';
|
||||
|
||||
@@ -64,6 +63,7 @@ export interface UserTabConfig extends TabVisibilityConfig {
|
||||
|
||||
export interface TabWindowConfig {
|
||||
userTabs: UserTabConfig[];
|
||||
developerTabs: DevTabConfig[];
|
||||
}
|
||||
|
||||
export const TAB_LABELS: Record<TabType, string> = {
|
||||
@@ -74,13 +74,13 @@ export const TAB_LABELS: Record<TabType, string> = {
|
||||
data: 'Data Management',
|
||||
'cloud-providers': 'Cloud Providers',
|
||||
'local-providers': 'Local Providers',
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
netlify: 'Netlify',
|
||||
vercel: 'Vercel',
|
||||
supabase: 'Supabase',
|
||||
'service-status': 'Service Status',
|
||||
connection: 'Connections',
|
||||
debug: 'Debug',
|
||||
'event-logs': 'Event Logs',
|
||||
mcp: 'MCP Servers',
|
||||
update: 'Updates',
|
||||
'task-manager': 'Task Manager',
|
||||
'tab-management': 'Tab Management',
|
||||
};
|
||||
|
||||
export const categoryLabels: Record<SettingCategory, string> = {
|
||||
@@ -92,13 +92,13 @@ export const categoryLabels: Record<SettingCategory, string> = {
|
||||
preferences: 'Preferences',
|
||||
};
|
||||
|
||||
export const categoryIcons: Record<SettingCategory, React.ComponentType<{ className?: string }>> = {
|
||||
profile: User,
|
||||
file_sharing: Folder,
|
||||
connectivity: Wifi,
|
||||
system: Settings,
|
||||
services: Box,
|
||||
preferences: Sliders,
|
||||
export const categoryIcons: Record<SettingCategory, string> = {
|
||||
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 interface Profile {
|
||||
|
||||
@@ -7,6 +7,8 @@ export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constan
|
||||
|
||||
// Shared components
|
||||
export { TabTile } from './shared/components/TabTile';
|
||||
export { TabManagement } from './shared/components/TabManagement';
|
||||
|
||||
// Utils
|
||||
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
|
||||
export * from './utils/animations';
|
||||
|
||||
163
app/components/@settings/shared/components/DraggableTabList.tsx
Normal file
163
app/components/@settings/shared/components/DraggableTabList.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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 (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isDragging ? 1.02 : 1,
|
||||
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-between p-4 rounded-lg',
|
||||
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
isDragging ? 'z-50' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="cursor-grab">
|
||||
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
|
||||
{showControls && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary">
|
||||
Order: {tab.order}, Window: {tab.window}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showControls && !tab.locked && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={tab.visible}
|
||||
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
|
||||
className="data-[state=checked]:bg-purple-500"
|
||||
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
|
||||
/>
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-bolt-elements-textSecondary">User</label>
|
||||
<Switch
|
||||
checked={tab.window === 'developer'}
|
||||
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
|
||||
className="data-[state=checked]:bg-purple-500"
|
||||
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
|
||||
/>
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
{tabs.map((tab, index) => (
|
||||
<DraggableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
index={index}
|
||||
moveTab={moveTab}
|
||||
showControls={showControls}
|
||||
onWindowChange={onWindowChange}
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
380
app/components/@settings/shared/components/TabManagement.tsx
Normal file
380
app/components/@settings/shared/components/TabManagement.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { tabConfigurationStore } from '~/lib/stores/settings';
|
||||
import { TAB_LABELS } from '~/components/@settings/core/constants';
|
||||
import type { TabType } from '~/components/@settings/core/types';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TbLayoutGrid } from 'react-icons/tb';
|
||||
import { useSettingsStore } from '~/lib/stores/settings';
|
||||
|
||||
// Define tab icons mapping
|
||||
const TAB_ICONS: Record<TabType, string> = {
|
||||
profile: 'i-ph:user-circle-fill',
|
||||
settings: 'i-ph:gear-six-fill',
|
||||
notifications: 'i-ph:bell-fill',
|
||||
features: 'i-ph:star-fill',
|
||||
data: 'i-ph:database-fill',
|
||||
'cloud-providers': 'i-ph:cloud-fill',
|
||||
'local-providers': 'i-ph:desktop-fill',
|
||||
'service-status': 'i-ph:activity-fill',
|
||||
connection: 'i-ph:wifi-high-fill',
|
||||
debug: 'i-ph:bug-fill',
|
||||
'event-logs': 'i-ph:list-bullets-fill',
|
||||
update: 'i-ph:arrow-clockwise-fill',
|
||||
'task-manager': 'i-ph:chart-line-fill',
|
||||
'tab-management': 'i-ph:squares-four-fill',
|
||||
};
|
||||
|
||||
// Define which tabs are default in user mode
|
||||
const DEFAULT_USER_TABS: TabType[] = [
|
||||
'features',
|
||||
'data',
|
||||
'cloud-providers',
|
||||
'local-providers',
|
||||
'connection',
|
||||
'notifications',
|
||||
'event-logs',
|
||||
];
|
||||
|
||||
// Define which tabs can be added to user mode
|
||||
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
|
||||
|
||||
// All available tabs for user mode
|
||||
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
|
||||
|
||||
// Define which tabs are beta
|
||||
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
||||
|
||||
// Beta label component
|
||||
const BetaLabel = () => (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-purple-500/10 text-purple-500 font-medium">BETA</span>
|
||||
);
|
||||
|
||||
export const TabManagement = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const { setSelectedTab } = useSettingsStore();
|
||||
|
||||
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
|
||||
// Get current tab configuration
|
||||
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
|
||||
|
||||
// If tab doesn't exist in configuration, create it
|
||||
if (!currentTab) {
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
visible: checked,
|
||||
window: 'user' as const,
|
||||
order: tabConfiguration.userTabs.length,
|
||||
};
|
||||
|
||||
const updatedTabs = [...tabConfiguration.userTabs, newTab];
|
||||
|
||||
tabConfigurationStore.set({
|
||||
...tabConfiguration,
|
||||
userTabs: updatedTabs,
|
||||
});
|
||||
|
||||
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tab can be enabled in user mode
|
||||
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
|
||||
|
||||
if (!canBeEnabled && checked) {
|
||||
toast.error('This tab cannot be enabled in user mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tab visibility
|
||||
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return { ...tab, visible: checked };
|
||||
}
|
||||
|
||||
return tab;
|
||||
});
|
||||
|
||||
// Update store
|
||||
tabConfigurationStore.set({
|
||||
...tabConfiguration,
|
||||
userTabs: updatedTabs,
|
||||
});
|
||||
|
||||
// Show success message
|
||||
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
||||
};
|
||||
|
||||
// Create a map of existing tab configurations
|
||||
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
|
||||
|
||||
// Generate the complete list of tabs, including those not in the configuration
|
||||
const allTabs = ALL_USER_TABS.map((tabId) => {
|
||||
return (
|
||||
tabConfigMap.get(tabId) || {
|
||||
id: tabId,
|
||||
visible: false,
|
||||
window: 'user' as const,
|
||||
order: -1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Filter tabs based on search query
|
||||
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
// Reset to first tab when component unmounts
|
||||
return () => {
|
||||
setSelectedTab('user'); // Reset to user tab when unmounting
|
||||
};
|
||||
}, [setSelectedTab]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg',
|
||||
'bg-bolt-elements-background-depth-3',
|
||||
'text-purple-500',
|
||||
)}
|
||||
>
|
||||
<TbLayoutGrid className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-64">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tabs..."
|
||||
className={classNames(
|
||||
'w-full pl-10 pr-4 py-2 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-2 focus:ring-purple-500/30',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Default Section Header */}
|
||||
{filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && (
|
||||
<div className="col-span-full flex items-center gap-2 mt-4 mb-2">
|
||||
<div className="i-ph:star-fill w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">Default Tabs</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Tabs */}
|
||||
{filteredTabs
|
||||
.filter((tab) => DEFAULT_USER_TABS.includes(tab.id))
|
||||
.map((tab, index) => (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
className={classNames(
|
||||
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'transition-all duration-200',
|
||||
'relative overflow-hidden group',
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{/* Status Badges */}
|
||||
<div className="absolute top-1 right-1.5 flex gap-1">
|
||||
<span className="px-1.5 py-0.25 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium mr-2">
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4">
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-10 h-10 flex items-center justify-center rounded-xl',
|
||||
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
||||
'transition-all duration-200',
|
||||
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
||||
)}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div
|
||||
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
||||
>
|
||||
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h4>
|
||||
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
||||
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tab.visible}
|
||||
onCheckedChange={(checked) => {
|
||||
const isDisabled =
|
||||
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
||||
|
||||
if (!isDisabled) {
|
||||
handleTabVisibilityChange(tab.id, checked);
|
||||
}
|
||||
}}
|
||||
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
||||
'opacity-50 pointer-events-none':
|
||||
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
||||
animate={{
|
||||
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
||||
scale: tab.visible ? 1 : 0.98,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Optional Section Header */}
|
||||
{filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && (
|
||||
<div className="col-span-full flex items-center gap-2 mt-8 mb-2">
|
||||
<div className="i-ph:plus-circle-fill w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">Optional Tabs</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Tabs */}
|
||||
{filteredTabs
|
||||
.filter((tab) => OPTIONAL_USER_TABS.includes(tab.id))
|
||||
.map((tab, index) => (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
className={classNames(
|
||||
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'transition-all duration-200',
|
||||
'relative overflow-hidden group',
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{/* Status Badges */}
|
||||
<div className="absolute top-1 right-1.5 flex gap-1">
|
||||
<span className="px-1.5 py-0.25 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium mr-2">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4">
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'w-10 h-10 flex items-center justify-center rounded-xl',
|
||||
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
||||
'transition-all duration-200',
|
||||
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
||||
)}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div
|
||||
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
||||
>
|
||||
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h4>
|
||||
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
||||
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={tab.visible}
|
||||
onCheckedChange={(checked) => {
|
||||
const isDisabled =
|
||||
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
||||
|
||||
if (!isDisabled) {
|
||||
handleTabVisibilityChange(tab.id, checked);
|
||||
}
|
||||
}}
|
||||
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
||||
'opacity-50 pointer-events-none':
|
||||
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
||||
animate={{
|
||||
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
||||
scale: tab.visible ? 1 : 0.98,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
||||
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
|
||||
import { GlowingEffect } from '~/components/ui/GlowingEffect';
|
||||
|
||||
interface TabTileProps {
|
||||
tab: TabVisibilityConfig;
|
||||
@@ -28,122 +28,106 @@ export const TabTile: React.FC<TabTileProps> = ({
|
||||
children,
|
||||
}: TabTileProps) => {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className={classNames('min-h-[160px] list-none', className || '')}>
|
||||
<div className="relative h-full rounded-xl border border-[#E5E5E5] dark:border-[#333333] p-0.5">
|
||||
<GlowingEffect
|
||||
blur={0}
|
||||
borderWidth={1}
|
||||
spread={20}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={40}
|
||||
inactiveZone={0.3}
|
||||
movementDuration={0.4}
|
||||
/>
|
||||
<div
|
||||
onClick={onClick}
|
||||
<motion.div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'relative flex flex-col items-center p-6 rounded-xl',
|
||||
'w-full h-full min-h-[160px]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'group',
|
||||
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
||||
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
||||
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
||||
isLoading ? 'cursor-wait opacity-70' : '',
|
||||
className || '',
|
||||
)}
|
||||
>
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'relative flex flex-col items-center justify-center h-full p-4 rounded-lg',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'group cursor-pointer',
|
||||
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
||||
'transition-colors duration-100 ease-out',
|
||||
isActive ? 'bg-purple-500/5 dark:bg-purple-500/10' : '',
|
||||
isLoading ? 'cursor-wait opacity-70 pointer-events-none' : '',
|
||||
'relative',
|
||||
'w-14 h-14',
|
||||
'flex items-center justify-center',
|
||||
'rounded-xl',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'ring-1 ring-gray-200 dark:ring-gray-700',
|
||||
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
||||
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
||||
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'relative',
|
||||
'w-14 h-14',
|
||||
'flex items-center justify-center',
|
||||
'rounded-xl',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'ring-1 ring-gray-200 dark:ring-gray-700',
|
||||
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
||||
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
||||
'transition-all duration-100 ease-out',
|
||||
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
||||
TAB_ICONS[tab.id],
|
||||
'w-8 h-8',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex flex-col items-center mt-5 w-full">
|
||||
<h3
|
||||
className={classNames(
|
||||
'text-[15px] font-medium leading-snug mb-2',
|
||||
'text-gray-700 dark:text-gray-200',
|
||||
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = TAB_ICONS[tab.id];
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames(
|
||||
'w-8 h-8',
|
||||
'text-gray-600 dark:text-gray-300',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
||||
'transition-colors duration-100 ease-out',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex flex-col items-center mt-4 w-full">
|
||||
<h3
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={classNames(
|
||||
'text-[15px] font-medium leading-snug mb-2',
|
||||
'text-gray-700 dark:text-gray-200',
|
||||
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
||||
'transition-colors duration-100 ease-out',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||
'text-[13px] leading-relaxed',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'max-w-[85%]',
|
||||
'text-center',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
||||
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
||||
)}
|
||||
>
|
||||
{TAB_LABELS[tab.id]}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={classNames(
|
||||
'text-[13px] leading-relaxed',
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'max-w-[85%]',
|
||||
'text-center',
|
||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
||||
'transition-colors duration-100 ease-out',
|
||||
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Indicator with Tooltip */}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg',
|
||||
'bg-[#18181B] text-white',
|
||||
'text-sm font-medium',
|
||||
'select-none',
|
||||
'z-[100]',
|
||||
)}
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
>
|
||||
{statusMessage}
|
||||
<Tooltip.Arrow className="fill-[#18181B]" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Children (e.g. Beta Label) */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Indicator with Tooltip */}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg',
|
||||
'bg-[#18181B] text-white',
|
||||
'text-sm font-medium',
|
||||
'select-none',
|
||||
'z-[100]',
|
||||
)}
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
>
|
||||
{statusMessage}
|
||||
<Tooltip.Arrow className="fill-[#18181B]" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Children (e.g. Beta Label) */}
|
||||
{children}
|
||||
</motion.div>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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 (
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{!isConnected ? (
|
||||
<div className="space-y-4">
|
||||
{environmentVariable && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set the{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
{environmentVariable}
|
||||
</code>{' '}
|
||||
environment variable to connect automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onConnect} className="space-y-4">
|
||||
{tokenTypes && tokenTypes.length > 1 && onTokenTypeChange && (
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
Token Type
|
||||
</label>
|
||||
<select
|
||||
value={selectedTokenType}
|
||||
onChange={(e) => onTokenTypeChange(e.target.value)}
|
||||
disabled={isConnecting}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{tokenTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTokenType && tokenTypes.find((t) => t.value === selectedTokenType)?.description && (
|
||||
<p className="mt-1 text-xs text-bolt-elements-textTertiary">
|
||||
{tokenTypes.find((t) => t.value === selectedTokenType)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">{tokenLabel}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={getTokenUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
{connectedMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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 (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'p-4 rounded-lg border',
|
||||
{
|
||||
'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700': testResult.status === 'success',
|
||||
'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-700': testResult.status === 'error',
|
||||
'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-700': testResult.status === 'testing',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.status === 'success' && (
|
||||
<div className="i-ph:check-circle w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
{testResult.status === 'error' && (
|
||||
<div className="i-ph:warning-circle w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
{testResult.status === 'testing' && (
|
||||
<div className="i-ph:spinner-gap w-5 h-5 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
<span
|
||||
className={classNames('text-sm font-medium', {
|
||||
'text-green-800 dark:text-green-200': testResult.status === 'success',
|
||||
'text-red-800 dark:text-red-200': testResult.status === 'error',
|
||||
'text-blue-800 dark:text-blue-200': testResult.status === 'testing',
|
||||
})}
|
||||
>
|
||||
{testResult.message}
|
||||
</span>
|
||||
</div>
|
||||
{testResult.timestamp && (
|
||||
<p className="text-xs text-gray-500 mt-1">{new Date(testResult.timestamp).toLocaleString()}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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 (
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'p-6 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/20',
|
||||
className,
|
||||
)}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="i-ph:warning-circle w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200 mb-1">{title}</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{errorMessage}</p>
|
||||
|
||||
{showDetails && isServiceError && error.details && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer hover:underline">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(error.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-700 border-red-300 hover:bg-red-100 dark:text-red-300 dark:border-red-600 dark:hover:bg-red-900/30"
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise w-4 h-4 mr-1" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
onClick={onDismiss}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-700 border-red-300 hover:bg-red-100 dark:text-red-300 dark:border-red-600 dark:hover:bg-red-900/30"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionErrorProps {
|
||||
service: string;
|
||||
error: ServiceError | string;
|
||||
onRetryConnection: () => void;
|
||||
onClearError?: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionError({ service, error, onRetryConnection, onClearError }: ConnectionErrorProps) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={error}
|
||||
title={`Failed to connect to ${service}`}
|
||||
onRetry={onRetryConnection}
|
||||
onDismiss={onClearError}
|
||||
retryLabel="Retry connection"
|
||||
showDetails={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 (
|
||||
<motion.div
|
||||
className={classNames('flex flex-col items-center justify-center gap-3', className)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:spinner-gap animate-spin text-bolt-elements-item-contentAccent',
|
||||
sizeClasses[size],
|
||||
)}
|
||||
/>
|
||||
<span className="text-bolt-elements-textSecondary">{message}</span>
|
||||
</div>
|
||||
|
||||
{showProgress && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="w-full bg-bolt-elements-background-depth-2 rounded-full h-1">
|
||||
<motion.div
|
||||
className="bg-bolt-elements-item-contentAccent h-1 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export function Skeleton({ className, lines = 1 }: SkeletonProps) {
|
||||
return (
|
||||
<div className={classNames('animate-pulse', className)}>
|
||||
{Array.from({ length: lines }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 rounded',
|
||||
i === lines - 1 ? 'h-4' : 'h-4 mb-2',
|
||||
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceLoadingProps {
|
||||
serviceName: string;
|
||||
operation: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function ServiceLoading({ serviceName, operation, progress }: ServiceLoadingProps) {
|
||||
return (
|
||||
<LoadingState
|
||||
message={`${operation} ${serviceName}...`}
|
||||
showProgress={progress !== undefined}
|
||||
progress={progress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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 (
|
||||
<>
|
||||
<motion.div
|
||||
className="flex items-center justify-between gap-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{additionalInfo}
|
||||
{onTestConnection && (
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isTestingConnection ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
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';
|
||||
@@ -0,0 +1,595 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Badge } from '~/components/ui/Badge';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
|
||||
import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
// Helper function to safely parse JSON
|
||||
const safeJsonParse = (item: string | null) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(item);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON from localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A diagnostics component to help troubleshoot connection issues
|
||||
*/
|
||||
export default function ConnectionDiagnostics() {
|
||||
const [diagnosticResults, setDiagnosticResults] = useState<any>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Run diagnostics when requested
|
||||
const runDiagnostics = async () => {
|
||||
try {
|
||||
setIsRunning(true);
|
||||
setDiagnosticResults(null);
|
||||
|
||||
// Check browser-side storage
|
||||
const localStorageChecks = {
|
||||
githubConnection: localStorage.getItem('github_connection'),
|
||||
netlifyConnection: localStorage.getItem('netlify_connection'),
|
||||
vercelConnection: localStorage.getItem('vercel_connection'),
|
||||
supabaseConnection: localStorage.getItem('supabase_connection'),
|
||||
};
|
||||
|
||||
// Get diagnostic data from server
|
||||
const response = await fetch('/api/system/diagnostics');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Diagnostics API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const serverDiagnostics = await response.json();
|
||||
|
||||
// === GitHub Checks ===
|
||||
const githubConnectionParsed = safeJsonParse(localStorageChecks.githubConnection);
|
||||
const githubToken = githubConnectionParsed?.token;
|
||||
const githubAuthHeaders = {
|
||||
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
|
||||
|
||||
const githubEndpoints = [
|
||||
{ name: 'User', url: '/api/system/git-info?action=getUser' },
|
||||
{ name: 'Repos', url: '/api/system/git-info?action=getRepos' },
|
||||
{ name: 'Default', url: '/api/system/git-info' },
|
||||
];
|
||||
const githubResults = await Promise.all(
|
||||
githubEndpoints.map(async (endpoint) => {
|
||||
try {
|
||||
const resp = await fetch(endpoint.url, { headers: githubAuthHeaders });
|
||||
return { endpoint: endpoint.name, status: resp.status, ok: resp.ok };
|
||||
} catch (error) {
|
||||
return {
|
||||
endpoint: endpoint.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// === Netlify Checks ===
|
||||
const netlifyConnectionParsed = safeJsonParse(localStorageChecks.netlifyConnection);
|
||||
const netlifyToken = netlifyConnectionParsed?.token;
|
||||
let netlifyUserCheck = null;
|
||||
|
||||
if (netlifyToken) {
|
||||
try {
|
||||
const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: { Authorization: `Bearer ${netlifyToken}` },
|
||||
});
|
||||
netlifyUserCheck = { status: netlifyResp.status, ok: netlifyResp.ok };
|
||||
} catch (error) {
|
||||
netlifyUserCheck = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === Vercel Checks ===
|
||||
const vercelConnectionParsed = safeJsonParse(localStorageChecks.vercelConnection);
|
||||
const vercelToken = vercelConnectionParsed?.token;
|
||||
let vercelUserCheck = null;
|
||||
|
||||
if (vercelToken) {
|
||||
try {
|
||||
const vercelResp = await fetch('https://api.vercel.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${vercelToken}` },
|
||||
});
|
||||
vercelUserCheck = { status: vercelResp.status, ok: vercelResp.ok };
|
||||
} catch (error) {
|
||||
vercelUserCheck = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === Supabase Checks ===
|
||||
const supabaseConnectionParsed = safeJsonParse(localStorageChecks.supabaseConnection);
|
||||
const supabaseUrl = supabaseConnectionParsed?.projectUrl;
|
||||
const supabaseAnonKey = supabaseConnectionParsed?.anonKey;
|
||||
let supabaseCheck = null;
|
||||
|
||||
if (supabaseUrl && supabaseAnonKey) {
|
||||
supabaseCheck = { ok: true, status: 200, message: 'URL and Key present in localStorage' };
|
||||
} else {
|
||||
supabaseCheck = { ok: false, message: 'URL or Key missing in localStorage' };
|
||||
}
|
||||
|
||||
// Compile results
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
localStorage: {
|
||||
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
|
||||
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
|
||||
hasVercelConnection: Boolean(localStorageChecks.vercelConnection),
|
||||
hasSupabaseConnection: Boolean(localStorageChecks.supabaseConnection),
|
||||
githubConnectionParsed,
|
||||
netlifyConnectionParsed,
|
||||
vercelConnectionParsed,
|
||||
supabaseConnectionParsed,
|
||||
},
|
||||
apiEndpoints: {
|
||||
github: githubResults,
|
||||
netlify: netlifyUserCheck,
|
||||
vercel: vercelUserCheck,
|
||||
supabase: supabaseCheck,
|
||||
},
|
||||
serverDiagnostics,
|
||||
};
|
||||
|
||||
setDiagnosticResults(results);
|
||||
|
||||
// Display simple results
|
||||
if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) {
|
||||
toast.error('GitHub API connections are failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) {
|
||||
toast.error('Netlify API connection is failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasVercelConnection && vercelUserCheck && !vercelUserCheck.ok) {
|
||||
toast.error('Vercel API connection is failing. Try reconnecting.');
|
||||
}
|
||||
|
||||
if (results.localStorage.hasSupabaseConnection && supabaseCheck && !supabaseCheck.ok) {
|
||||
toast.warning('Supabase connection check failed or missing details. Verify settings.');
|
||||
}
|
||||
|
||||
if (
|
||||
!results.localStorage.hasGithubConnection &&
|
||||
!results.localStorage.hasNetlifyConnection &&
|
||||
!results.localStorage.hasVercelConnection &&
|
||||
!results.localStorage.hasSupabaseConnection
|
||||
) {
|
||||
toast.info('No connection data found in browser storage.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Diagnostics error:', error);
|
||||
toast.error('Error running diagnostics');
|
||||
setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset GitHub connection
|
||||
const resetGitHubConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('github_connection');
|
||||
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
toast.success('GitHub connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing GitHub data:', error);
|
||||
toast.error('Failed to clear GitHub connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Netlify connection
|
||||
const resetNetlifyConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('netlify_connection');
|
||||
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
toast.success('Netlify connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Netlify data:', error);
|
||||
toast.error('Failed to clear Netlify connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Vercel connection
|
||||
const resetVercelConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('vercel_connection');
|
||||
toast.success('Vercel connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Vercel data:', error);
|
||||
toast.error('Failed to clear Vercel connection data');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset Supabase connection
|
||||
const resetSupabaseConnection = () => {
|
||||
try {
|
||||
localStorage.removeItem('supabase_connection');
|
||||
toast.success('Supabase connection data cleared. Please refresh the page and reconnect.');
|
||||
setDiagnosticResults(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing Supabase data:', error);
|
||||
toast.error('Failed to clear Supabase connection data');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Connection Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* GitHub Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitHub Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasGithubConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasGithubConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={
|
||||
diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok)
|
||||
? 'default'
|
||||
: 'destructive'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasGithubConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Netlify Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-bolt:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Netlify Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasNetlifyConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasNetlifyConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User:{' '}
|
||||
{diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name ||
|
||||
diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email ||
|
||||
'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.netlify?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasNetlifyConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vercel Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-si:vercel text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Vercel Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasVercelConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasVercelConnection ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasVercelConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
User:{' '}
|
||||
{diagnosticResults.localStorage.vercelConnectionParsed?.user?.username ||
|
||||
diagnosticResults.localStorage.vercelConnectionParsed?.user?.user?.username ||
|
||||
'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
API Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.vercel?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.vercel?.ok ? 'OK' : 'Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasVercelConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Connect Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supabase Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-si:supabase text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Supabase Connection
|
||||
</div>
|
||||
</div>
|
||||
{diagnosticResults ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xl font-semibold',
|
||||
diagnosticResults.localStorage.hasSupabaseConnection
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{diagnosticResults.localStorage.hasSupabaseConnection ? 'Configured' : 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticResults.localStorage.hasSupabaseConnection && (
|
||||
<>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5 truncate">
|
||||
<div className="i-ph:link w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent flex-shrink-0" />
|
||||
Project URL: {diagnosticResults.localStorage.supabaseConnectionParsed?.projectUrl || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
||||
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
Config Status:{' '}
|
||||
<Badge
|
||||
variant={diagnosticResults.apiEndpoints.supabase?.ok ? 'default' : 'destructive'}
|
||||
className="ml-1"
|
||||
>
|
||||
{diagnosticResults.apiEndpoints.supabase?.ok ? 'OK' : 'Check Failed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!diagnosticResults.localStorage.hasSupabaseConnection && (
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
|
||||
Configure Now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
Run diagnostics to check connection status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button
|
||||
onClick={runDiagnostics}
|
||||
disabled={isRunning}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<div className="i-ph:activity w-4 h-4" />
|
||||
)}
|
||||
{isRunning ? 'Running Diagnostics...' : 'Run Diagnostics'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetGitHubConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasGithubConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-ph:github-logo w-4 h-4" />
|
||||
Reset GitHub
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetNetlifyConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasNetlifyConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:netlify w-4 h-4" />
|
||||
Reset Netlify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetVercelConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasVercelConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:vercel w-4 h-4" />
|
||||
Reset Vercel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={resetSupabaseConnection}
|
||||
disabled={isRunning || !diagnosticResults?.localStorage.hasSupabaseConnection}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="i-si:supabase w-4 h-4" />
|
||||
Reset Supabase
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Details Panel */}
|
||||
{diagnosticResults && (
|
||||
<div className="mt-4">
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="w-full">
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeBracketIcon className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Diagnostic Details
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={classNames(
|
||||
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary',
|
||||
showDetails ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="p-4 mt-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
|
||||
<pre className="text-xs overflow-auto max-h-96 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{JSON.stringify(diagnosticResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
app/components/@settings/tabs/connections/ConnectionsTab.tsx
Normal file
184
app/components/@settings/tabs/connections/ConnectionsTab.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import ConnectionDiagnostics from './ConnectionDiagnostics';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import VercelConnection from './VercelConnection';
|
||||
|
||||
// Use React.lazy for dynamic imports
|
||||
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
|
||||
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
<div className="p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
|
||||
<div className="flex items-center justify-center gap-2 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
<span>Loading connection...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ConnectionsTab() {
|
||||
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between gap-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Connection Settings
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowDiagnostics(!showDiagnostics)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{showDiagnostics ? (
|
||||
<>
|
||||
<div className="i-ph:eye-slash w-4 h-4" />
|
||||
Hide Diagnostics
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:wrench w-4 h-4" />
|
||||
Troubleshoot Connections
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Manage your external service connections and integrations
|
||||
</p>
|
||||
|
||||
{/* Diagnostics Tool - Conditionally rendered */}
|
||||
{showDiagnostics && <ConnectionDiagnostics />}
|
||||
|
||||
{/* Environment Variables Info - Collapsible */}
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6">
|
||||
<button
|
||||
onClick={() => setIsEnvVarsExpanded(!isEnvVarsExpanded)}
|
||||
className={classNames(
|
||||
'w-full bg-transparent flex items-center justify-between',
|
||||
'hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary',
|
||||
'dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary',
|
||||
'rounded-md p-2 -m-2 transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:info w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Environment Variables
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary transition-transform',
|
||||
isEnvVarsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isEnvVarsExpanded && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
You can configure connections using environment variables in your{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
.env.local
|
||||
</code>{' '}
|
||||
file:
|
||||
</p>
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-3 rounded-md text-xs font-mono overflow-x-auto">
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
# GitHub Authentication
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_GITHUB_ACCESS_TOKEN=your_token_here
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
# Optional: Specify token type (defaults to 'classic' if not specified)
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_GITHUB_TOKEN_TYPE=classic|fine-grained
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2">
|
||||
# Netlify Authentication
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
VITE_NETLIFY_ACCESS_TOKEN=your_token_here
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary space-y-1">
|
||||
<p>
|
||||
<span className="font-medium">Token types:</span>
|
||||
</p>
|
||||
<ul className="list-disc list-inside pl-2 space-y-1">
|
||||
<li>
|
||||
<span className="font-medium">classic</span> - Personal Access Token with{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
repo, read:org, read:user
|
||||
</code>{' '}
|
||||
scopes
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">fine-grained</span> - Fine-grained token with Repository and
|
||||
Organization access
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
When set, these variables will be used automatically without requiring manual connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<GitHubConnection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<NetlifyConnection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<VercelConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Additional help text */}
|
||||
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-4 rounded-lg">
|
||||
<p className="flex items-center gap-1 mb-2">
|
||||
<span className="i-ph:lightbulb w-4 h-4 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Troubleshooting Tip:</span>
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can
|
||||
help diagnose and fix common connection issues.
|
||||
</p>
|
||||
<p>For persistent issues:</p>
|
||||
<ol className="list-decimal list-inside pl-4 mt-1">
|
||||
<li>Check your browser console for errors</li>
|
||||
<li>Verify that your tokens have the correct permissions</li>
|
||||
<li>Try clearing your browser cache and cookies</li>
|
||||
<li>Ensure your browser allows third-party cookies if using integrations</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
980
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
980
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
@@ -0,0 +1,980 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import Cookies from 'js-cookie';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
publicRepos: number;
|
||||
privateRepos: number;
|
||||
stars: number;
|
||||
forks: number;
|
||||
followers: number;
|
||||
publicGists: number;
|
||||
privateGists: number;
|
||||
lastUpdated: string;
|
||||
|
||||
// Keep these for backward compatibility
|
||||
totalStars?: number;
|
||||
totalForks?: number;
|
||||
organizations?: GitHubOrganization[];
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
rateLimit?: {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Add the GitHub logo SVG component
|
||||
const GithubLogo = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function GitHubConnection() {
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
||||
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
console.log('Fetching GitHub user with token:', token.substring(0, 5) + '...');
|
||||
|
||||
// Use server-side API endpoint instead of direct GitHub API call
|
||||
const response = await fetch(`/api/system/git-info?action=getUser`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`, // Include token in headers for validation
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Error fetching GitHub user. Status:', response.status);
|
||||
throw new Error(`Error: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get rate limit information from headers
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
console.log('GitHub user API response:', data);
|
||||
|
||||
const { user } = data as { user: GitHubUserResponse };
|
||||
|
||||
// Validate that we received a user object
|
||||
if (!user || !user.login) {
|
||||
console.error('Invalid user data received:', user);
|
||||
throw new Error('Invalid user data received');
|
||||
}
|
||||
|
||||
// Use the response data
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
rateLimit,
|
||||
}));
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('githubUsername', user.login);
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('Connected to GitHub', {
|
||||
type: 'system',
|
||||
message: `Connected to GitHub as ${user.login}`,
|
||||
});
|
||||
|
||||
// Fetch additional GitHub stats
|
||||
fetchGitHubStats(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub user:', error);
|
||||
logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'GitHub authentication failed',
|
||||
});
|
||||
|
||||
toast.error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error; // Rethrow to allow handling in the calling function
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
try {
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
if (userResponse.status === 401) {
|
||||
toast.error('Your GitHub token has expired. Please reconnect your account.');
|
||||
handleDisconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
|
||||
}
|
||||
|
||||
const userData = (await userResponse.json()) as any;
|
||||
|
||||
// Fetch repositories with pagination
|
||||
let allRepos: any[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as any[];
|
||||
allRepos = [...allRepos, ...repos];
|
||||
|
||||
// Check if there are more pages
|
||||
const linkHeader = reposResponse.headers.get('Link');
|
||||
hasMore = linkHeader?.includes('rel="next"') ?? false;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const repoStats = calculateRepoStats(allRepos);
|
||||
|
||||
// Fetch recent activity
|
||||
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const events = (await eventsResponse.json()) as any[];
|
||||
const recentActivity = events.slice(0, 5).map((event: any) => ({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
repo: event.repo.name,
|
||||
created_at: event.created_at,
|
||||
}));
|
||||
|
||||
// Calculate total stars and forks
|
||||
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
|
||||
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
|
||||
|
||||
// Update the stats in the store
|
||||
const stats: GitHubStats = {
|
||||
repos: repoStats.repos,
|
||||
recentActivity,
|
||||
languages: repoStats.languages || {},
|
||||
totalGists: repoStats.totalGists || 0,
|
||||
publicRepos: userData.public_repos || 0,
|
||||
privateRepos: privateRepos || 0,
|
||||
stars: totalStars || 0,
|
||||
forks: totalForks || 0,
|
||||
followers: userData.followers || 0,
|
||||
publicGists: userData.public_gists || 0,
|
||||
privateGists: userData.private_gists || 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
|
||||
// For backward compatibility
|
||||
totalStars: totalStars || 0,
|
||||
totalForks: totalForks || 0,
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
|
||||
const currentUser = currentConnection.user || connection.user;
|
||||
|
||||
// Update connection with stats
|
||||
const updatedConnection: GitHubConnection = {
|
||||
user: currentUser,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
stats,
|
||||
rateLimit: connection.rateLimit,
|
||||
};
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
// Update state
|
||||
setConnection(updatedConnection);
|
||||
|
||||
toast.success('GitHub stats refreshed');
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error);
|
||||
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRepoStats = (repos: any[]) => {
|
||||
const repoStats = {
|
||||
repos: repos.map((repo: any) => ({
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
html_url: repo.html_url,
|
||||
description: repo.description,
|
||||
stargazers_count: repo.stargazers_count,
|
||||
forks_count: repo.forks_count,
|
||||
default_branch: repo.default_branch,
|
||||
updated_at: repo.updated_at,
|
||||
languages_url: repo.languages_url,
|
||||
})),
|
||||
|
||||
languages: {} as Record<string, number>,
|
||||
totalGists: 0,
|
||||
};
|
||||
|
||||
repos.forEach((repo: any) => {
|
||||
fetch(repo.languages_url)
|
||||
.then((response) => response.json())
|
||||
.then((languages: any) => {
|
||||
const typedLanguages = languages as Record<string, number>;
|
||||
Object.keys(typedLanguages).forEach((language) => {
|
||||
if (!repoStats.languages[language]) {
|
||||
repoStats.languages[language] = 0;
|
||||
}
|
||||
|
||||
repoStats.languages[language] += 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return repoStats;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedConnection = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
// Update the ref with the parsed token type
|
||||
tokenTypeRef.current = parsed.tokenType;
|
||||
|
||||
// Set the connection
|
||||
setConnection(parsed);
|
||||
|
||||
// If we have a token but no stats or incomplete stats, fetch them
|
||||
if (
|
||||
parsed.user &&
|
||||
parsed.token &&
|
||||
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
|
||||
) {
|
||||
console.log('Fetching missing GitHub stats for saved connection');
|
||||
await fetchGitHubStats(parsed.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved GitHub connection:', error);
|
||||
localStorage.removeItem('github_connection');
|
||||
}
|
||||
} else {
|
||||
// Check for environment variable token
|
||||
const envToken = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
|
||||
|
||||
if (envToken) {
|
||||
// Check if token type is specified in environment variables
|
||||
const envTokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE;
|
||||
console.log('Environment token type:', envTokenType);
|
||||
|
||||
const tokenType =
|
||||
envTokenType === 'classic' || envTokenType === 'fine-grained'
|
||||
? (envTokenType as 'classic' | 'fine-grained')
|
||||
: 'classic';
|
||||
|
||||
console.log('Using token type:', tokenType);
|
||||
|
||||
// Update both the state and the ref
|
||||
tokenTypeRef.current = tokenType;
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
tokenType,
|
||||
}));
|
||||
|
||||
try {
|
||||
// Fetch user data with the environment token
|
||||
await fetchGithubUser(envToken);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect with environment token:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadSavedConnection();
|
||||
}, []);
|
||||
|
||||
// Ensure cookies are updated when connection changes
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = connection.token;
|
||||
const data = connection.user;
|
||||
|
||||
if (token) {
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
}
|
||||
|
||||
if (data) {
|
||||
Cookies.set('githubUsername', data.login);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// Add function to update rate limits
|
||||
const updateRateLimits = async (token: string) => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/rate_limit', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
rateLimit,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rate limits:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to update rate limits periodically
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (connection.token && connection.user) {
|
||||
updateRateLimits(connection.token);
|
||||
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [connection.token, connection.user]);
|
||||
|
||||
if (isLoading || isConnecting || isFetchingStats) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Update the ref with the current state value before connecting
|
||||
tokenTypeRef.current = connection.tokenType;
|
||||
|
||||
/*
|
||||
* Save token type to localStorage even before connecting
|
||||
* This ensures the token type is persisted even if connection fails
|
||||
*/
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user: null,
|
||||
token: connection.token,
|
||||
tokenType: connection.tokenType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Attempt to fetch the user info which validates the token
|
||||
await fetchGithubUser(connection.token);
|
||||
|
||||
toast.success('Connected to GitHub successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitHub:', error);
|
||||
|
||||
// Reset connection state on failure
|
||||
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
|
||||
|
||||
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
|
||||
// Remove all GitHub-related cookies
|
||||
Cookies.remove('githubToken');
|
||||
Cookies.remove('githubUsername');
|
||||
Cookies.remove('git:github.com');
|
||||
|
||||
// Reset the token type ref
|
||||
tokenTypeRef.current = 'classic';
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('Disconnected from GitHub');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitHub Connection
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set the{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_ACCESS_TOKEN
|
||||
</code>{' '}
|
||||
environment variable to connect automatically.
|
||||
</p>
|
||||
<p>
|
||||
For fine-grained tokens, also set{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_TOKEN_TYPE=fine-grained
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
Token Type
|
||||
</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) => {
|
||||
const newTokenType = e.target.value as 'classic' | 'fine-grained';
|
||||
tokenTypeRef.current = newTokenType;
|
||||
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
|
||||
}}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`Enter your GitHub ${
|
||||
connection.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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!connection.user ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:layout w-4 h-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
fetchGitHubStats(connection.token);
|
||||
updateRateLimits(connection.token);
|
||||
}}
|
||||
disabled={isFetchingStats}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{isFetchingStats ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:arrows-clockwise w-4 h-4" />
|
||||
Refresh Stats
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg mb-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent dark:border-bolt-elements-item-contentAccent"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
{connection.user.login}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Stats</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||
isStatsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{
|
||||
label: 'Member Since',
|
||||
value: new Date(connection.user.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: 'Public Gists',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
|
||||
},
|
||||
{
|
||||
label: 'Languages',
|
||||
value: Object.keys(connection.stats.languages).length,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public Repos',
|
||||
value: connection.stats.publicRepos,
|
||||
},
|
||||
{
|
||||
label: 'Private Repos',
|
||||
value: connection.stats.privateRepos,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Stars',
|
||||
value: connection.stats.stars || 0,
|
||||
icon: 'i-ph:star',
|
||||
iconColor: 'text-bolt-elements-icon-warning',
|
||||
},
|
||||
{
|
||||
label: 'Forks',
|
||||
value: connection.stats.forks || 0,
|
||||
icon: 'i-ph:git-fork',
|
||||
iconColor: 'text-bolt-elements-icon-info',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: connection.stats.followers || 0,
|
||||
icon: 'i-ph:users',
|
||||
iconColor: 'text-bolt-elements-icon-success',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Gists</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Private',
|
||||
value: connection.stats.privateGists || 0,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-bolt-elements-borderColor">
|
||||
<span className="text-xs text-bolt-elements-textSecondary">
|
||||
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Recent Repositories</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-tertiary" />
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 ml-auto group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,6 @@ 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';
|
||||
@@ -44,23 +42,12 @@ interface SiteAction {
|
||||
}
|
||||
|
||||
export default function NetlifyConnection() {
|
||||
console.log('NetlifyConnection component mounted');
|
||||
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [fetchingStats, setFetchingStats] = useState(false);
|
||||
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||
|
||||
console.log('NetlifyConnection initial state:', {
|
||||
connection: {
|
||||
user: connection.user,
|
||||
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
|
||||
},
|
||||
envToken: import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
|
||||
});
|
||||
|
||||
const [deploymentCount, setDeploymentCount] = useState(0);
|
||||
const [lastUpdated, setLastUpdated] = useState('');
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
@@ -75,74 +62,15 @@ export default function NetlifyConnection() {
|
||||
icon: ArrowPathIcon,
|
||||
action: async (siteId: string) => {
|
||||
try {
|
||||
// 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`, {
|
||||
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/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}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to clear cache');
|
||||
}
|
||||
|
||||
toast.success('Site cache cleared successfully');
|
||||
@@ -152,174 +80,6 @@ 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,
|
||||
@@ -380,8 +140,6 @@ export default function NetlifyConnection() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Netlify: Running initialization useEffect');
|
||||
|
||||
// Initialize connection with environment token if available
|
||||
initializeNetlifyConnection();
|
||||
}, []);
|
||||
@@ -919,13 +677,7 @@ export default function NetlifyConnection() {
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
{/* Debug info - remove this later */}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
|
||||
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
|
||||
<p>Debug: Env token: {import.meta.env?.VITE_NETLIFY_ACCESS_TOKEN ? '✅' : '❌'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
@@ -949,17 +701,6 @@ export default function NetlifyConnection() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Debug button - remove this later */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
console.log('Manual Netlify auto-connect test');
|
||||
await initializeNetlifyConnection();
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
Test Auto-Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
@@ -10,64 +10,22 @@ import {
|
||||
isFetchingStats,
|
||||
updateVercelConnection,
|
||||
fetchVercelStats,
|
||||
autoConnectVercel,
|
||||
} from '~/lib/stores/vercel';
|
||||
|
||||
export default function VercelConnection() {
|
||||
console.log('VercelConnection component mounted');
|
||||
|
||||
const connection = useStore(vercelConnection);
|
||||
const connecting = useStore(isConnecting);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
console.log('VercelConnection initial state:', {
|
||||
connection: {
|
||||
user: connection.user,
|
||||
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
|
||||
},
|
||||
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent multiple initializations
|
||||
if (hasInitialized.current) {
|
||||
console.log('Vercel: Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeConnection = async () => {
|
||||
console.log('Vercel initializeConnection:', {
|
||||
user: connection.user,
|
||||
token: connection.token ? '[TOKEN_EXISTS]' : '[NO_TOKEN]',
|
||||
envToken: import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '[ENV_TOKEN_EXISTS]' : '[NO_ENV_TOKEN]',
|
||||
});
|
||||
|
||||
hasInitialized.current = true;
|
||||
|
||||
// Auto-connect using environment variable if no existing connection but token exists
|
||||
if (!connection.user && connection.token && import.meta.env?.VITE_VERCEL_ACCESS_TOKEN) {
|
||||
console.log('Vercel: Attempting auto-connection');
|
||||
|
||||
const result = await autoConnectVercel();
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Connected to Vercel automatically');
|
||||
} else {
|
||||
console.error('Vercel auto-connection failed:', result.error);
|
||||
}
|
||||
} else if (connection.user && connection.token) {
|
||||
// Fetch stats for existing connection
|
||||
console.log('Vercel: Fetching stats for existing connection');
|
||||
const fetchProjects = async () => {
|
||||
if (connection.user && connection.token) {
|
||||
await fetchVercelStats(connection.token);
|
||||
} else {
|
||||
console.log('Vercel: No auto-connection conditions met');
|
||||
}
|
||||
};
|
||||
|
||||
initializeConnection();
|
||||
}, []); // Empty dependency array to run only once
|
||||
fetchProjects();
|
||||
}, [connection.user, connection.token]);
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -160,68 +118,32 @@ export default function VercelConnection() {
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
<div className="mt-2 text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-2 rounded">
|
||||
<p className="flex items-center gap-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded text-xs">
|
||||
VITE_VERCEL_ACCESS_TOKEN
|
||||
</code>{' '}
|
||||
in your .env.local for automatic connection.
|
||||
</p>
|
||||
</div>
|
||||
{/* Debug info - remove this later */}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<p>Debug: Token present: {connection.token ? '✅' : '❌'}</p>
|
||||
<p>Debug: User present: {connection.user ? '✅' : '❌'}</p>
|
||||
<p>Debug: Env token: {import.meta.env?.VITE_VERCEL_ACCESS_TOKEN ? '✅' : '❌'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Debug button - remove this later */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
console.log('Manual auto-connect test');
|
||||
|
||||
const result = await autoConnectVercel();
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Manual auto-connect successful');
|
||||
} else {
|
||||
toast.error(`Manual auto-connect failed: ${result.error}`);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-xs bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
Test Auto-Connect
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
|
||||
const GITHUB_TOKEN_KEY = 'github_token';
|
||||
|
||||
interface ConnectionFormProps {
|
||||
authState: GitHubAuthState;
|
||||
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
|
||||
onSave: (e: React.FormEvent) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
|
||||
|
||||
if (savedToken && !authState.tokenInfo?.token) {
|
||||
setAuthState((prev: GitHubAuthState) => ({
|
||||
...prev,
|
||||
tokenInfo: {
|
||||
token: savedToken,
|
||||
scope: [],
|
||||
avatar_url: '',
|
||||
name: null,
|
||||
created_at: new Date().toISOString(),
|
||||
followers: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
// Ensure the token is also saved with the correct key for API requests
|
||||
Cookies.set('githubToken', savedToken);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
||||
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSave} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
||||
GitHub Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={authState.username}
|
||||
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
|
||||
className={classNames(
|
||||
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
||||
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
placeholder="e.g., octocat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
|
||||
Personal Access Token
|
||||
</label>
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-1.5 text-xs',
|
||||
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
>
|
||||
<span>Generate new token</span>
|
||||
<div className="i-ph:plus-circle" />
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
id="token"
|
||||
type="password"
|
||||
value={authState.tokenInfo?.token || ''}
|
||||
onChange={(e) =>
|
||||
setAuthState((prev: GitHubAuthState) => ({
|
||||
...prev,
|
||||
tokenInfo: {
|
||||
token: e.target.value,
|
||||
scope: [],
|
||||
avatar_url: '',
|
||||
name: null,
|
||||
created_at: new Date().toISOString(),
|
||||
followers: 0,
|
||||
},
|
||||
username: '',
|
||||
isConnected: false,
|
||||
isVerifying: false,
|
||||
isLoadingRepos: false,
|
||||
}))
|
||||
}
|
||||
className={classNames(
|
||||
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
||||
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
placeholder="ghp_xxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
|
||||
<div className="flex items-center gap-4">
|
||||
{!authState.isConnected ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
||||
'bg-purple-500 hover:bg-purple-600',
|
||||
'text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{authState.isVerifying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner animate-spin" />
|
||||
<span>Verifying...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-fill" />
|
||||
<span>Connect</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
||||
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
|
||||
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
|
||||
'text-bolt-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug-fill" />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
|
||||
<div className="i-ph:check-circle-fill" />
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{authState.rateLimits && (
|
||||
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:clock-countdown opacity-60" />
|
||||
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
|
||||
import { GitBranch } from '@phosphor-icons/react';
|
||||
|
||||
interface GitHubBranch {
|
||||
name: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
interface CreateBranchDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (branchName: string, sourceBranch: string) => void;
|
||||
repository: GitHubRepoInfo;
|
||||
branches?: GitHubBranch[];
|
||||
}
|
||||
|
||||
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onConfirm(branchName, sourceBranch);
|
||||
setBranchName('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
|
||||
<Dialog.Content
|
||||
className={classNames(
|
||||
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-full max-w-md p-6 rounded-xl shadow-lg',
|
||||
'bg-white dark:bg-[#0A0A0A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
)}
|
||||
>
|
||||
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
|
||||
Create New Branch
|
||||
</Dialog.Title>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
||||
Branch Name
|
||||
</label>
|
||||
<input
|
||||
id="branchName"
|
||||
type="text"
|
||||
value={branchName}
|
||||
onChange={(e) => setBranchName(e.target.value)}
|
||||
placeholder="feature/my-new-branch"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg',
|
||||
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="sourceBranch"
|
||||
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
|
||||
>
|
||||
Source Branch
|
||||
</label>
|
||||
<select
|
||||
id="sourceBranch"
|
||||
value={sourceBranch}
|
||||
onChange={(e) => setSourceBranch(e.target.value)}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg',
|
||||
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||
)}
|
||||
>
|
||||
{branches?.map((branch) => (
|
||||
<option key={branch.name} value={branch.name}>
|
||||
{branch.name} {branch.default ? '(default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
|
||||
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
|
||||
<li className="flex items-center gap-2">
|
||||
<GitBranch className="text-lg" />
|
||||
Repository: {repository.name}
|
||||
</li>
|
||||
{branchName && (
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="i-ph:check-circle text-green-500" />
|
||||
New branch will be created as: {branchName}
|
||||
</li>
|
||||
)}
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="i-ph:check-circle text-green-500" />
|
||||
Based on: {sourceBranch}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
||||
'hover:bg-purple-500/10 hover:text-purple-500',
|
||||
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium',
|
||||
'text-white bg-purple-500',
|
||||
'hover:bg-purple-600',
|
||||
'transition-colors',
|
||||
)}
|
||||
>
|
||||
Create Branch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import Cookies from 'js-cookie';
|
||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||
|
||||
interface GitHubAuthDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) {
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = (await response.json()) as GitHubUserResponse;
|
||||
|
||||
// Save connection data
|
||||
const connectionData = {
|
||||
token,
|
||||
tokenType,
|
||||
user: {
|
||||
login: userData.login,
|
||||
avatar_url: userData.avatar_url,
|
||||
name: userData.name || userData.login,
|
||||
},
|
||||
connected_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(connectionData));
|
||||
|
||||
// Set cookies for API requests
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', userData.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
toast.success(`Successfully connected as ${userData.login}`);
|
||||
setToken('');
|
||||
onClose();
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
toast.error('Invalid GitHub token. Please check and try again.');
|
||||
} else {
|
||||
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting to GitHub:', error);
|
||||
toast.error('Failed to connect to GitHub. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
|
||||
|
||||
<p className="text-sm text-[#666666] dark:text-[#999999]">
|
||||
To access private repositories, you need to connect your GitHub account by providing a personal access
|
||||
token.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
|
||||
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
|
||||
GitHub Personal Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
|
||||
Get your token at{' '}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={tokenType === 'classic'}
|
||||
onChange={() => setTokenType('classic')}
|
||||
className="w-3.5 h-3.5 accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={tokenType === 'fine-grained'}
|
||||
onChange={() => setTokenType('fine-grained')}
|
||||
className="w-3.5 h-3.5 accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
|
||||
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
|
||||
<span className="i-ph:warning-circle w-4 h-4" />
|
||||
Accessing Private Repositories
|
||||
</h3>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Important things to know about accessing private repositories:
|
||||
</p>
|
||||
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
|
||||
<li>You must be granted access to the repository by its owner</li>
|
||||
<li>Your GitHub token must have the 'repo' scope</li>
|
||||
<li>For organization repositories, you may need additional permissions</li>
|
||||
<li>No token can give you access to repositories you don't have permission for</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-transparent bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,75 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { getLocalStorage } from '~/lib/persistence/localStorage';
|
||||
import type { GitLabUserResponse, GitLabProjectInfo } from '~/types/GitLab';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
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';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
interface GitLabDeploymentDialogProps {
|
||||
// Internal imports
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { extractRelativePath } from '~/utils/diff';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
import type { FileMap, File } from '~/lib/stores/files';
|
||||
|
||||
// UI Components
|
||||
import { Badge, EmptyState, StatusIndicator, SearchInput } from '~/components/ui';
|
||||
|
||||
interface PushToGitHubDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectName: string;
|
||||
files: Record<string, string>;
|
||||
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
|
||||
}
|
||||
|
||||
export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }: GitLabDeploymentDialogProps) {
|
||||
interface GitHubRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
language: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
|
||||
const [repoName, setRepoName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useState<GitLabUserResponse | null>(null);
|
||||
const [recentRepos, setRecentRepos] = useState<GitLabProjectInfo[]>([]);
|
||||
const [filteredRepos, setFilteredRepos] = useState<GitLabProjectInfo[]>([]);
|
||||
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
||||
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
||||
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
|
||||
const [repoSearchQuery, setRepoSearchQuery] = useState('');
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
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
|
||||
// Load GitHub connection on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const connection = getLocalStorage('gitlab_connection');
|
||||
|
||||
// Set a default repository name based on the project name
|
||||
setRepoName(projectName.replace(/\s+/g, '-').toLowerCase());
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection?.user && connection?.token) {
|
||||
setUser(connection.user);
|
||||
|
||||
// Only fetch if we have both user and token
|
||||
if (connection.token.trim()) {
|
||||
fetchRecentRepos(connection.token, connection.gitlabUrl || 'https://gitlab.com');
|
||||
fetchRecentRepos(connection.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, projectName]);
|
||||
}, [isOpen]);
|
||||
|
||||
/*
|
||||
* Filter repositories based on search query
|
||||
* const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300);
|
||||
*/
|
||||
|
||||
// Filter repositories based on search query
|
||||
useEffect(() => {
|
||||
if (recentRepos.length === 0) {
|
||||
setFilteredRepos([]);
|
||||
@@ -69,43 +84,113 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
const query = repoSearchQuery.toLowerCase().trim();
|
||||
const filtered = recentRepos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(query) || (repo.description && repo.description.toLowerCase().includes(query)),
|
||||
repo.name.toLowerCase().includes(query) ||
|
||||
(repo.description && repo.description.toLowerCase().includes(query)) ||
|
||||
(repo.language && repo.language.toLowerCase().includes(query)),
|
||||
);
|
||||
|
||||
setFilteredRepos(filtered);
|
||||
}, [recentRepos, repoSearchQuery]);
|
||||
|
||||
const fetchRecentRepos = async (token: string, gitlabUrl = 'https://gitlab.com') => {
|
||||
const fetchRecentRepos = useCallback(async (token: string) => {
|
||||
if (!token) {
|
||||
logStore.logError('No GitLab token available');
|
||||
toast.error('GitLab authentication required');
|
||||
logStore.logError('No GitHub token available');
|
||||
toast.error('GitHub authentication required');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingRepos(true);
|
||||
console.log('Fetching GitHub repositories with token:', token.substring(0, 5) + '...');
|
||||
|
||||
const apiService = new GitLabApiService(token, gitlabUrl);
|
||||
const repos = await apiService.getProjects();
|
||||
setRecentRepos(repos);
|
||||
// Fetch ALL repos by paginating through all pages
|
||||
let allRepos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const requestUrl = `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,organization_member`;
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: { message?: string } = {};
|
||||
|
||||
try {
|
||||
errorData = await response.json();
|
||||
console.error('Error response data:', errorData);
|
||||
} catch (e) {
|
||||
errorData = { message: 'Could not parse error response' };
|
||||
console.error('Could not parse error response:', e);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
toast.error('GitHub token expired. Please reconnect your account.');
|
||||
|
||||
// Clear invalid token
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection) {
|
||||
localStorage.removeItem('github_connection');
|
||||
setUser(null);
|
||||
}
|
||||
} else if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
|
||||
// Rate limit exceeded
|
||||
const resetTime = response.headers.get('x-ratelimit-reset');
|
||||
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'soon';
|
||||
toast.error(`GitHub API rate limit exceeded. Limit resets at ${resetDate}`);
|
||||
} else {
|
||||
logStore.logError('Failed to fetch GitHub repositories', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
});
|
||||
toast.error(`Failed to fetch repositories: ${errorData.message || response.statusText}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repos = (await response.json()) as GitHubRepo[];
|
||||
allRepos = allRepos.concat(repos);
|
||||
|
||||
if (repos.length < 100) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page += 1;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing JSON response:', parseError);
|
||||
logStore.logError('Failed to parse GitHub repositories response', { parseError });
|
||||
toast.error('Failed to parse repository data');
|
||||
setRecentRepos([]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRecentRepos(allRepos);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitLab repositories:', error);
|
||||
logStore.logError('Failed to fetch GitLab repositories', { error });
|
||||
console.error('Exception while fetching GitHub repositories:', error);
|
||||
logStore.logError('Failed to fetch GitHub repositories', { error });
|
||||
toast.error('Failed to fetch recent repositories');
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Function to create a new repository or push to an existing one
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const connection = getLocalStorage('gitlab_connection');
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (!connection?.token || !connection?.user) {
|
||||
toast.error('Please connect your GitLab account in Settings > Connections first');
|
||||
toast.error('Please connect your GitHub account in Settings > Connections first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,129 +201,62 @@ 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 repository exists first
|
||||
const octokit = new Octokit({ auth: connection.token });
|
||||
|
||||
// Warn user if repository name was changed
|
||||
if (sanitizedRepoName !== repoName && sanitizedRepoName !== repoName.toLowerCase()) {
|
||||
toast.info(`Repository name sanitized to "${sanitizedRepoName}" to meet GitLab requirements`);
|
||||
}
|
||||
try {
|
||||
const { data: existingRepo } = await octokit.repos.get({
|
||||
owner: connection.user.login,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
// Check if project exists using the sanitized name
|
||||
const projectPath = `${connection.user.username}/${sanitizedRepoName}`;
|
||||
const existingProject = await apiService.getProjectByPath(projectPath);
|
||||
const projectExists = existingProject !== null;
|
||||
// If we get here, the repo exists
|
||||
let confirmMessage = `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`;
|
||||
|
||||
if (projectExists && existingProject) {
|
||||
// Confirm overwrite
|
||||
const visibilityChange =
|
||||
existingProject.visibility !== (isPrivate ? 'private' : 'public')
|
||||
? `\n\nThis will also change the repository from ${existingProject.visibility} to ${isPrivate ? 'private' : 'public'}.`
|
||||
: '';
|
||||
// Add visibility change warning if needed
|
||||
if (existingRepo.private !== isPrivate) {
|
||||
const visibilityChange = isPrivate
|
||||
? 'This will also change the repository from public to private.'
|
||||
: 'This will also change the repository from private to public.';
|
||||
|
||||
const confirmOverwrite = window.confirm(
|
||||
`Repository "${sanitizedRepoName}" already exists. Do you want to update it? This will add or modify files in the repository.${visibilityChange}`,
|
||||
);
|
||||
confirmMessage += `\n\n${visibilityChange}`;
|
||||
}
|
||||
|
||||
const confirmOverwrite = window.confirm(confirmMessage);
|
||||
|
||||
if (!confirmOverwrite) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update visibility if needed
|
||||
if (existingProject.visibility !== (isPrivate ? 'private' : 'public')) {
|
||||
toast.info('Updating repository visibility...');
|
||||
await apiService.updateProjectVisibility(existingProject.id, isPrivate ? 'private' : 'public');
|
||||
} catch (error) {
|
||||
// 404 means repo doesn't exist, which is what we want for new repos
|
||||
if (error instanceof Error && 'status' in error && error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Update project with files
|
||||
toast.info('Uploading files to existing repository...');
|
||||
await apiService.updateProjectWithFiles(existingProject.id, files);
|
||||
setCreatedRepoUrl(existingProject.http_url_to_repo);
|
||||
toast.success('Repository updated successfully!');
|
||||
} else {
|
||||
// Create new project with files
|
||||
toast.info('Creating new repository...');
|
||||
|
||||
const newProject = await apiService.createProjectWithFiles(sanitizedRepoName, isPrivate, files);
|
||||
setCreatedRepoUrl(newProject.http_url_to_repo);
|
||||
toast.success('Repository created successfully!');
|
||||
}
|
||||
|
||||
// Set pushed files for display
|
||||
const fileList = Object.entries(files).map(([filePath, content]) => ({
|
||||
path: filePath,
|
||||
size: new TextEncoder().encode(content).length,
|
||||
}));
|
||||
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
|
||||
setCreatedRepoUrl(repoUrl);
|
||||
|
||||
setPushedFiles(fileList);
|
||||
// Get list of pushed files
|
||||
const files = workbenchStore.files.get();
|
||||
const filesList = Object.entries(files as FileMap)
|
||||
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
|
||||
.map(([path, dirent]) => ({
|
||||
path: extractRelativePath(path),
|
||||
size: new TextEncoder().encode((dirent as File).content || '').length,
|
||||
}));
|
||||
|
||||
setPushedFiles(filesList);
|
||||
setShowSuccessDialog(true);
|
||||
|
||||
// Save repository info
|
||||
localStorage.setItem(
|
||||
`gitlab-repo-${currentChatId}`,
|
||||
JSON.stringify({
|
||||
owner: connection.user.username,
|
||||
name: sanitizedRepoName,
|
||||
url: createdRepoUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('GitLab deployment completed successfully', {
|
||||
type: 'system',
|
||||
message: `Successfully deployed ${fileList.length} files to ${projectExists ? 'existing' : 'new'} GitLab repository: ${projectPath}`,
|
||||
repoName: sanitizedRepoName,
|
||||
projectPath,
|
||||
filesCount: fileList.length,
|
||||
isNewProject: !projectExists,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitLab:', error);
|
||||
|
||||
logStore.logError('GitLab deployment failed', {
|
||||
error,
|
||||
repoName: sanitizedRepoName,
|
||||
projectPath: `${connection.user.username}/${sanitizedRepoName}`,
|
||||
});
|
||||
|
||||
// Provide specific error messages based on error type
|
||||
let errorMessage = 'Failed to push to GitLab';
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMsg = error.message.toLowerCase();
|
||||
|
||||
if (errorMsg.includes('404') || errorMsg.includes('not found')) {
|
||||
errorMessage =
|
||||
'Repository or GitLab instance not found. Please check your GitLab URL and repository permissions.';
|
||||
} else if (errorMsg.includes('401') || errorMsg.includes('unauthorized')) {
|
||||
errorMessage = 'GitLab authentication failed. Please check your access token and permissions.';
|
||||
} else if (errorMsg.includes('403') || errorMsg.includes('forbidden')) {
|
||||
errorMessage =
|
||||
'Access denied. Your GitLab token may not have sufficient permissions to create/modify repositories.';
|
||||
} else if (errorMsg.includes('network') || errorMsg.includes('fetch')) {
|
||||
errorMessage = 'Network error. Please check your internet connection and try again.';
|
||||
} else if (errorMsg.includes('timeout')) {
|
||||
errorMessage = 'Request timed out. Please try again or check your connection.';
|
||||
} else if (errorMsg.includes('rate limit')) {
|
||||
errorMessage = 'GitLab API rate limit exceeded. Please wait a moment and try again.';
|
||||
} else {
|
||||
errorMessage = `GitLab error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setRepoName('');
|
||||
@@ -248,18 +266,6 @@ 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 (
|
||||
@@ -278,7 +284,6 @@ export function GitLabDeploymentDialog({ 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"
|
||||
>
|
||||
<Dialog.Title className="sr-only">Successfully pushed to GitLab</Dialog.Title>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -287,13 +292,13 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Successfully pushed to GitLab
|
||||
Successfully pushed to GitHub
|
||||
</h3>
|
||||
<p
|
||||
id="success-dialog-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
Your code is now available on GitLab
|
||||
Your code is now available on GitHub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +315,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 text-left border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
|
||||
<span className="i-ph:gitlab-logo w-4 h-4 text-orange-500" />
|
||||
<span className="i-ph:github-logo w-4 h-4 text-purple-500" />
|
||||
Repository URL
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -337,7 +342,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
Pushed Files ({pushedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{pushedFiles.slice(0, 100).map((file) => (
|
||||
{pushedFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between py-1.5 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark border-b border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 last:border-0"
|
||||
@@ -348,11 +353,6 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{pushedFiles.length > 100 && (
|
||||
<div className="py-2 text-center text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
+{pushedFiles.length - 100} more files
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,11 +361,11 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
href={createdRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-lg bg-orange-500 text-white hover:bg-orange-600 text-sm inline-flex items-center gap-2"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:gitlab-logo w-4 h-4" />
|
||||
<div className="i-ph:github-logo w-4 h-4" />
|
||||
View Repository
|
||||
</motion.a>
|
||||
<motion.button
|
||||
@@ -415,7 +415,6 @@ export function GitLabDeploymentDialog({ 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"
|
||||
>
|
||||
<Dialog.Title className="sr-only">GitLab Connection Required</Dialog.Title>
|
||||
<div className="relative text-center space-y-4">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
@@ -430,18 +429,19 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-orange-500"
|
||||
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:gitlab-logo w-8 h-8" />
|
||||
<div className="i-ph:github-logo w-8 h-8" />
|
||||
</motion.div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
GitLab Connection Required
|
||||
GitHub Connection Required
|
||||
</h3>
|
||||
<p
|
||||
id="connection-required-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark max-w-md mx-auto"
|
||||
>
|
||||
To deploy your code to GitLab, you need to connect your GitLab account first.
|
||||
To push your code to GitHub, you need to connect your GitHub account in Settings {'>'} Connections
|
||||
first.
|
||||
</p>
|
||||
<div className="pt-2 flex justify-center gap-3">
|
||||
<motion.button
|
||||
@@ -452,24 +452,21 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
>
|
||||
Close
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => 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"
|
||||
<motion.a
|
||||
href="/settings/connections"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:gitlab-logo w-4 h-4" />
|
||||
Connect GitLab Account
|
||||
</motion.button>
|
||||
<div className="i-ph:gear" />
|
||||
Go to Settings
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
|
||||
{/* GitLab Auth Dialog */}
|
||||
<GitLabAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -496,19 +493,19 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-orange-500"
|
||||
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:gitlab-logo w-5 h-5" />
|
||||
<div className="i-ph:github-logo w-5 h-5" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Deploy to GitLab
|
||||
Push to GitHub
|
||||
</Dialog.Title>
|
||||
<p
|
||||
id="push-dialog-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
Deploy your code to a new or existing GitLab repository
|
||||
Push your code to a new or existing GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
@@ -524,62 +521,17 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 p-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="relative">
|
||||
{user.avatar_url && user.avatar_url !== 'null' && user.avatar_url !== '' ? (
|
||||
<img
|
||||
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;
|
||||
target.style.display = 'none';
|
||||
|
||||
const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
|
||||
|
||||
if (fallback) {
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// Ensure fallback is hidden when image loads successfully
|
||||
const target = e.target as HTMLImageElement;
|
||||
|
||||
const fallback = target.parentElement?.querySelector('.avatar-fallback') as HTMLElement;
|
||||
|
||||
if (fallback) {
|
||||
fallback.style.display = 'none';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="avatar-fallback w-10 h-10 rounded-full bg-bolt-elements-background-depth-4 flex items-center justify-center text-bolt-elements-textSecondary font-semibold text-sm"
|
||||
style={{
|
||||
display:
|
||||
user.avatar_url && user.avatar_url !== 'null' && user.avatar_url !== '' ? 'none' : 'flex',
|
||||
}}
|
||||
>
|
||||
{user.name ? (
|
||||
user.name.charAt(0).toUpperCase()
|
||||
) : user.username ? (
|
||||
user.username.charAt(0).toUpperCase()
|
||||
) : (
|
||||
<div className="i-ph:user w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center text-white">
|
||||
<div className="i-ph:gitlab-logo w-3 h-3" />
|
||||
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center text-white">
|
||||
<div className="i-ph:github-logo w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
{user.name || user.username}
|
||||
{user.name || user.login}
|
||||
</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
@{user.username}
|
||||
@{user.login}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -602,7 +554,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
placeholder="my-awesome-project"
|
||||
className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -630,9 +582,9 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
|
||||
{recentRepos.length === 0 && !isFetchingRepos ? (
|
||||
<EmptyState
|
||||
icon="i-ph:gitlab-logo"
|
||||
icon="i-ph:github-logo"
|
||||
title="No repositories found"
|
||||
description="We couldn't find any repositories in your GitLab account."
|
||||
description="We couldn't find any repositories in your GitHub account."
|
||||
variant="compact"
|
||||
/>
|
||||
) : (
|
||||
@@ -647,21 +599,21 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
) : (
|
||||
filteredRepos.map((repo) => (
|
||||
<motion.button
|
||||
key={repo.id}
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => setRepoName(repo.name)}
|
||||
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-orange-500/30"
|
||||
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-branch w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-orange-500">
|
||||
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-purple-500">
|
||||
{repo.name}
|
||||
</span>
|
||||
</div>
|
||||
{repo.visibility === 'private' && (
|
||||
{repo.private && (
|
||||
<Badge variant="primary" size="sm" icon="i-ph:lock w-3 h-3">
|
||||
Private
|
||||
</Badge>
|
||||
@@ -673,8 +625,13 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
{repo.language && (
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:code w-3 h-3">
|
||||
{repo.language}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:star w-3 h-3">
|
||||
{repo.star_count.toLocaleString()}
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</Badge>
|
||||
<Badge variant="subtle" size="sm" icon="i-ph:git-fork w-3 h-3">
|
||||
{repo.forks_count.toLocaleString()}
|
||||
@@ -695,7 +652,6 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
<StatusIndicator status="loading" pulse={true} label="Loading repositories..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -703,7 +659,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
id="private"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-orange-500 focus:ring-orange-500 dark:bg-bolt-elements-background-depth-3"
|
||||
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3"
|
||||
/>
|
||||
<label
|
||||
htmlFor="private"
|
||||
@@ -731,7 +687,7 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm inline-flex items-center justify-center gap-2',
|
||||
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
|
||||
isLoading ? 'opacity-50 cursor-not-allowed' : '',
|
||||
)}
|
||||
whileHover={!isLoading ? { scale: 1.02 } : {}}
|
||||
@@ -740,12 +696,12 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||
Deploying...
|
||||
Pushing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:gitlab-logo w-4 h-4" />
|
||||
Deploy to GitLab
|
||||
<div className="i-ph:github-logo w-4 h-4" />
|
||||
Push to GitHub
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
@@ -756,9 +712,6 @@ export function GitLabDeploymentDialog({ isOpen, onClose, projectName, files }:
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
|
||||
{/* GitLab Auth Dialog */}
|
||||
<GitLabAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||
|
||||
interface RepositoryCardProps {
|
||||
repo: GitHubRepoInfo;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function RepositoryCard({ repo, onSelect }: RepositoryCardProps) {
|
||||
// Use a consistent styling for all repository cards
|
||||
const getCardStyle = () => {
|
||||
return 'from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark';
|
||||
};
|
||||
|
||||
// Format the date in a more readable format
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 1) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
if (diffDays <= 2) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
if (diffDays <= 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
if (diffDays <= 30) {
|
||||
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const cardStyle = useMemo(() => getCardStyle(), []);
|
||||
|
||||
// const formattedDate = useMemo(() => formatDate(repo.updated_at), [repo.updated_at]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`p-5 rounded-xl bg-gradient-to-br ${cardStyle} border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md`}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
y: -2,
|
||||
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80 backdrop-blur-sm flex items-center justify-center text-purple-500 shadow-sm">
|
||||
<span className="i-ph:git-branch w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
|
||||
{repo.name}
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||
<span className="i-ph:user w-3 h-3" />
|
||||
{repo.full_name.split('/')[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={onSelect}
|
||||
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-3.5 h-3.5" />
|
||||
Import
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{repo.private && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-purple-500/10 text-purple-600 dark:text-purple-400 text-xs">
|
||||
<span className="i-ph:lock w-3 h-3" />
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:code w-3 h-3" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
{repo.forks_count > 0 && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<span className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:clock w-3 h-3" />
|
||||
Updated {formatDate(repo.updated_at)}
|
||||
</span>
|
||||
|
||||
{repo.topics && repo.topics.length > 0 && (
|
||||
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
{repo.topics.slice(0, 1).map((topic) => (
|
||||
<span
|
||||
key={topic}
|
||||
className="px-1.5 py-0.5 rounded-full bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 text-xs"
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
{repo.topics.length > 1 && <span className="ml-1">+{repo.topics.length - 1}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Create a context to share the setShowAuthDialog function with child components
|
||||
export interface RepositoryDialogContextType {
|
||||
setShowAuthDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// Default context value with a no-op function
|
||||
export const RepositoryDialogContext = createContext<RepositoryDialogContextType>({
|
||||
// This is intentionally empty as it will be overridden by the provider
|
||||
setShowAuthDialog: () => {
|
||||
// No operation
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useContext } from 'react';
|
||||
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||
import { EmptyState, StatusIndicator } from '~/components/ui';
|
||||
import { RepositoryCard } from './RepositoryCard';
|
||||
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||
|
||||
interface RepositoryListProps {
|
||||
repos: GitHubRepoInfo[];
|
||||
isLoading: boolean;
|
||||
onSelect: (repo: GitHubRepoInfo) => void;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
export function RepositoryList({ repos, isLoading, onSelect, activeTab }: RepositoryListProps) {
|
||||
// Access the parent component's setShowAuthDialog function
|
||||
const { setShowAuthDialog } = useContext(RepositoryDialogContext);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<StatusIndicator status="loading" pulse={true} size="lg" label="Loading repositories..." className="mb-2" />
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
This may take a moment
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (repos.length === 0) {
|
||||
if (activeTab === 'my-repos') {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="i-ph:folder-simple-dashed"
|
||||
title="No repositories found"
|
||||
description="Connect your GitHub account or create a new repository to get started"
|
||||
actionLabel="Connect GitHub Account"
|
||||
onAction={() => setShowAuthDialog(true)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="i-ph:magnifying-glass"
|
||||
title="No repositories found"
|
||||
description="Try searching with different keywords or filters"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { getLocalStorage } from '~/lib/persistence';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
// Import UI components
|
||||
import { Input, SearchInput, Badge, FilterChip } from '~/components/ui';
|
||||
|
||||
// Import the components we've extracted
|
||||
import { RepositoryList } from './RepositoryList';
|
||||
import { StatsDialog } from './StatsDialog';
|
||||
import { GitHubAuthDialog } from './GitHubAuthDialog';
|
||||
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||
|
||||
interface GitHubTreeResponse {
|
||||
tree: Array<{
|
||||
path: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RepositorySelectionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (url: string) => void;
|
||||
}
|
||||
|
||||
interface SearchFilters {
|
||||
language?: string;
|
||||
stars?: number;
|
||||
forks?: number;
|
||||
}
|
||||
|
||||
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState('');
|
||||
const [filters, setFilters] = useState<SearchFilters>({});
|
||||
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
||||
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
||||
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
|
||||
// Handle GitHub auth dialog close and refresh repositories
|
||||
const handleAuthDialogClose = () => {
|
||||
setShowAuthDialog(false);
|
||||
|
||||
// If we're on the my-repos tab, refresh the repository list
|
||||
if (activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize GitHub connection and fetch repositories
|
||||
useEffect(() => {
|
||||
const savedConnection = getLocalStorage('github_connection');
|
||||
|
||||
// If no connection exists but environment variables are set, create a connection
|
||||
if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
|
||||
const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
|
||||
const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic';
|
||||
|
||||
// Fetch GitHub user info to initialize the connection
|
||||
fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then((data: unknown) => {
|
||||
const userData = data as GitHubUserResponse;
|
||||
|
||||
// Save connection to local storage
|
||||
const newConnection = {
|
||||
token,
|
||||
tokenType,
|
||||
user: {
|
||||
login: userData.login,
|
||||
avatar_url: userData.avatar_url,
|
||||
name: userData.name || userData.login,
|
||||
},
|
||||
connected_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
|
||||
// Also save as cookies for API requests
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('githubUsername', userData.login);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Refresh repositories after connection is established
|
||||
if (isOpen && activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to initialize GitHub connection from environment variables:', error);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch repositories when dialog opens or tab changes
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === 'my-repos') {
|
||||
fetchUserRepos();
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
|
||||
const fetchUserRepos = async () => {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (!connection?.token) {
|
||||
toast.error('Please connect your GitHub account first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch repositories');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add type assertion and validation
|
||||
if (
|
||||
Array.isArray(data) &&
|
||||
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
|
||||
) {
|
||||
setRepositories(data as GitHubRepoInfo[]);
|
||||
} else {
|
||||
throw new Error('Invalid repository data format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching repos:', error);
|
||||
toast.error('Failed to fetch your repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
setIsLoading(true);
|
||||
setSearchResults([]);
|
||||
|
||||
try {
|
||||
let searchQuery = query;
|
||||
|
||||
if (filters.language) {
|
||||
searchQuery += ` language:${filters.language}`;
|
||||
}
|
||||
|
||||
if (filters.stars) {
|
||||
searchQuery += ` stars:>${filters.stars}`;
|
||||
}
|
||||
|
||||
if (filters.forks) {
|
||||
searchQuery += ` forks:>${filters.forks}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search repositories');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add type assertion and validation
|
||||
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
|
||||
setSearchResults(data.items as GitHubRepoInfo[]);
|
||||
} else {
|
||||
throw new Error('Invalid search results format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching repos:', error);
|
||||
toast.error('Failed to search repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBranches = async (repo: GitHubRepoInfo) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
const headers: HeadersInit = connection?.token
|
||||
? {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
}
|
||||
: {};
|
||||
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch branches');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add type assertion and validation
|
||||
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
|
||||
setBranches(
|
||||
data.map((branch) => ({
|
||||
name: branch.name,
|
||||
default: branch.name === repo.default_branch,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
throw new Error('Invalid branch data format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
toast.error('Failed to fetch branches');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
|
||||
setSelectedRepository(repo);
|
||||
await fetchBranches(repo);
|
||||
};
|
||||
|
||||
const formatGitUrl = (url: string): string => {
|
||||
// Remove any tree references and ensure .git extension
|
||||
const baseUrl = url
|
||||
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
.replace(/\.git$/, ''); // Remove .git if present
|
||||
return `${baseUrl}.git`;
|
||||
};
|
||||
|
||||
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
||||
try {
|
||||
// Extract branch from URL if present (format: url#branch)
|
||||
let branch: string | null = null;
|
||||
let cleanUrl = repoUrl;
|
||||
|
||||
if (repoUrl.includes('#')) {
|
||||
const parts = repoUrl.split('#');
|
||||
cleanUrl = parts[0];
|
||||
branch = parts[1];
|
||||
}
|
||||
|
||||
const [owner, repo] = cleanUrl
|
||||
.replace(/\.git$/, '')
|
||||
.split('/')
|
||||
.slice(-2);
|
||||
|
||||
// Try to get token from local storage first
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
// If no connection in local storage, check environment variables
|
||||
let headers: HeadersInit = {};
|
||||
|
||||
if (connection?.token) {
|
||||
headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
};
|
||||
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
|
||||
// Use token from environment variables
|
||||
headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`,
|
||||
};
|
||||
}
|
||||
|
||||
// First, get the repository info to determine the default branch
|
||||
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!repoInfoResponse.ok) {
|
||||
if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) {
|
||||
throw new Error(
|
||||
`Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`,
|
||||
);
|
||||
} else if (repoInfoResponse.status === 404) {
|
||||
throw new Error(
|
||||
`Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const repoInfo = (await repoInfoResponse.json()) as { default_branch: string };
|
||||
let defaultBranch = repoInfo.default_branch || 'main';
|
||||
|
||||
// If a branch was specified in the URL, use that instead of the default
|
||||
if (branch) {
|
||||
defaultBranch = branch;
|
||||
}
|
||||
|
||||
// Try to fetch the repository tree using the selected branch
|
||||
let treeResponse = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
// If the selected branch doesn't work, try common branch names
|
||||
if (!treeResponse.ok) {
|
||||
// Try 'master' branch if default branch failed
|
||||
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// If master also fails, try 'main' branch
|
||||
if (!treeResponse.ok) {
|
||||
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// If all common branches fail, throw an error
|
||||
if (!treeResponse.ok) {
|
||||
throw new Error(
|
||||
'Failed to fetch repository structure. Please check the repository URL and your access permissions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
|
||||
|
||||
// Calculate repository stats
|
||||
let totalSize = 0;
|
||||
let totalFiles = 0;
|
||||
const languages: { [key: string]: number } = {};
|
||||
let hasPackageJson = false;
|
||||
let hasDependencies = false;
|
||||
|
||||
for (const file of treeData.tree) {
|
||||
if (file.type === 'blob') {
|
||||
totalFiles++;
|
||||
|
||||
if (file.size) {
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
// Check for package.json
|
||||
if (file.path === 'package.json') {
|
||||
hasPackageJson = true;
|
||||
|
||||
// Fetch package.json content to check dependencies
|
||||
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (contentResponse.ok) {
|
||||
const content = (await contentResponse.json()) as GitHubContent;
|
||||
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
|
||||
hasDependencies = !!(
|
||||
packageJson.dependencies ||
|
||||
packageJson.devDependencies ||
|
||||
packageJson.peerDependencies
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect language based on file extension
|
||||
const ext = file.path.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (ext) {
|
||||
languages[ext] = (languages[ext] || 0) + (file.size || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stats: RepositoryStats = {
|
||||
totalFiles,
|
||||
totalSize,
|
||||
languages,
|
||||
hasPackageJson,
|
||||
hasDependencies,
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Error verifying repository:', error);
|
||||
|
||||
// Check if it's an authentication error and show the auth dialog
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository';
|
||||
|
||||
if (
|
||||
errorMessage.includes('Authentication failed') ||
|
||||
errorMessage.includes('may be private') ||
|
||||
errorMessage.includes('Repository not found or is private') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('403') ||
|
||||
errorMessage.includes('404') ||
|
||||
errorMessage.includes('access permissions')
|
||||
) {
|
||||
setShowAuthDialog(true);
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
let gitUrl: string;
|
||||
|
||||
if (activeTab === 'url' && customUrl) {
|
||||
gitUrl = formatGitUrl(customUrl);
|
||||
} else if (selectedRepository) {
|
||||
gitUrl = formatGitUrl(selectedRepository.html_url);
|
||||
|
||||
if (selectedBranch) {
|
||||
gitUrl = `${gitUrl}#${selectedBranch}`;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify repository before importing
|
||||
const stats = await verifyRepository(gitUrl);
|
||||
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStats(stats);
|
||||
setPendingGitUrl(gitUrl);
|
||||
setShowStatsDialog(true);
|
||||
} catch (error) {
|
||||
console.error('Error preparing repository:', error);
|
||||
|
||||
// Check if it's an authentication error
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.';
|
||||
|
||||
// Show the GitHub auth dialog for any authentication or permission errors
|
||||
if (
|
||||
errorMessage.includes('Authentication failed') ||
|
||||
errorMessage.includes('may be private') ||
|
||||
errorMessage.includes('Repository not found or is private') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('403') ||
|
||||
errorMessage.includes('404') ||
|
||||
errorMessage.includes('access permissions')
|
||||
) {
|
||||
// Directly show the auth dialog instead of just showing a toast
|
||||
setShowAuthDialog(true);
|
||||
|
||||
toast.error(
|
||||
<div className="space-y-2">
|
||||
<p>{errorMessage}</p>
|
||||
<button onClick={() => setShowAuthDialog(true)} className="underline font-medium block text-purple-500">
|
||||
Learn how to access private repositories
|
||||
</button>
|
||||
</div>,
|
||||
{ autoClose: 10000 }, // Keep the toast visible longer
|
||||
);
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatsConfirm = () => {
|
||||
setShowStatsDialog(false);
|
||||
|
||||
if (pendingGitUrl) {
|
||||
onSelect(pendingGitUrl);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
|
||||
let parsedValue: string | number | undefined = value;
|
||||
|
||||
if (key === 'stars' || key === 'forks') {
|
||||
parsedValue = value ? parseInt(value, 10) : undefined;
|
||||
}
|
||||
|
||||
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
|
||||
handleSearch(searchQuery);
|
||||
};
|
||||
|
||||
// Handle dialog close properly
|
||||
const handleClose = () => {
|
||||
setIsLoading(false); // Reset loading state
|
||||
setSearchQuery(''); // Reset search
|
||||
setSearchResults([]); // Reset results
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<RepositoryDialogContext.Provider value={{ setShowAuthDialog }}>
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
||||
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[650px] max-h-[85vh] overflow-hidden bg-white dark:bg-bolt-elements-background-depth-1 rounded-xl shadow-xl z-[51] border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/10 flex items-center justify-center text-purple-500 shadow-sm">
|
||||
<span className="i-ph:github-logo w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Import GitHub Repository
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Clone a repository from GitHub to your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
onClick={handleClose}
|
||||
className={classNames(
|
||||
'p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent',
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
||||
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
||||
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
|
||||
)}
|
||||
>
|
||||
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Close dialog</span>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Auth Info Banner */}
|
||||
<div className="p-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between bg-gradient-to-r from-bolt-elements-background-depth-2 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-3 dark:to-bolt-elements-background-depth-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:info text-blue-500" />
|
||||
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Need to access private repositories?
|
||||
</span>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => setShowAuthDialog(true)}
|
||||
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5 shadow-sm"
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 4px 8px rgba(124, 58, 237, 0.2)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="i-ph:github-logo w-4 h-4" />
|
||||
Connect GitHub Account
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="bg-[#f0f0f0] dark:bg-[#1e1e1e] rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('my-repos')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'my-repos'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
My Repos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'search'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('url')}
|
||||
className={classNames(
|
||||
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||
activeTab === 'url'
|
||||
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||
)}
|
||||
>
|
||||
From URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'url' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="bg-gradient-to-br from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||
<span className="i-ph:link-simple w-4 h-4 text-purple-500" />
|
||||
Repository URL
|
||||
</h3>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-purple-500">
|
||||
<span className="i-ph:github-logo w-5 h-5" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter GitHub repository URL (e.g., https://github.com/user/repo)"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
className="w-full pl-10 py-3 border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||
<span>
|
||||
You can paste any GitHub repository URL, including specific branches or tags.
|
||||
<br />
|
||||
<span className="text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
Example: https://github.com/username/repository/tree/branch-name
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||
<span>Ready to import?</span>
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleImport}
|
||||
disabled={!customUrl}
|
||||
className={classNames(
|
||||
'w-full h-12 px-4 py-2 rounded-xl text-white transition-all duration-200 flex items-center gap-2 justify-center',
|
||||
customUrl
|
||||
? 'bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 shadow-md'
|
||||
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
|
||||
)}
|
||||
whileHover={customUrl ? { scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' } : {}}
|
||||
whileTap={customUrl ? { scale: 0.98 } : {}}
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||
Import Repository
|
||||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'search' && (
|
||||
<div className="space-y-5 mb-5">
|
||||
<div className="bg-gradient-to-br from-blue-500/5 to-cyan-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||
<span className="i-ph:magnifying-glass w-4 h-4 text-blue-500" />
|
||||
Search GitHub
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<SearchInput
|
||||
placeholder="Search GitHub repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (e.target.value.length > 2) {
|
||||
handleSearch(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
iconClassName="text-blue-500"
|
||||
className="py-3 bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm"
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => setFilters({})}
|
||||
className="px-3 py-2 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title="Clear filters"
|
||||
>
|
||||
<span className="i-ph:funnel-simple w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
Filters
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{(filters.language || filters.stars || filters.forks) && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<AnimatePresence>
|
||||
{filters.language && (
|
||||
<FilterChip
|
||||
label="Language"
|
||||
value={filters.language}
|
||||
icon="i-ph:code"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.language;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filters.stars && (
|
||||
<FilterChip
|
||||
label="Stars"
|
||||
value={`>${filters.stars}`}
|
||||
icon="i-ph:star"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.stars;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filters.forks && (
|
||||
<FilterChip
|
||||
label="Forks"
|
||||
value={`>${filters.forks}`}
|
||||
icon="i-ph:git-fork"
|
||||
active
|
||||
onRemove={() => {
|
||||
const newFilters = { ...filters };
|
||||
delete newFilters.forks;
|
||||
setFilters(newFilters);
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="relative col-span-3 md:col-span-1">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:code w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Language (e.g., javascript)"
|
||||
value={filters.language || ''}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, language: e.target.value });
|
||||
|
||||
if (searchQuery.length > 2) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:star w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min stars"
|
||||
value={filters.stars || ''}
|
||||
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||
<span className="i-ph:git-fork w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min forks"
|
||||
value={filters.forks || ''}
|
||||
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
||||
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||
<span>
|
||||
Search for repositories by name, description, or topics. Use filters to narrow down
|
||||
results.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{selectedRepository ? (
|
||||
<div className="space-y-5 bg-gradient-to-br from-purple-500/5 to-blue-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.button
|
||||
onClick={() => setSelectedRepository(null)}
|
||||
className="p-2 rounded-lg hover:bg-white dark:hover:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary shadow-sm"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<span className="i-ph:arrow-left w-4 h-4" />
|
||||
</motion.button>
|
||||
<div>
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-lg">
|
||||
{selectedRepository.name}
|
||||
</h3>
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||
<span className="i-ph:user w-3 h-3" />
|
||||
{selectedRepository.full_name.split('/')[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRepository.private && (
|
||||
<Badge variant="primary" size="md" icon="i-ph:lock w-3 h-3">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRepository.description && (
|
||||
<div className="bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
{selectedRepository.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedRepository.language && (
|
||||
<Badge variant="subtle" size="md" icon="i-ph:code w-3 h-3">
|
||||
{selectedRepository.language}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="subtle" size="md" icon="i-ph:star w-3 h-3">
|
||||
{selectedRepository.stargazers_count.toLocaleString()}
|
||||
</Badge>
|
||||
{selectedRepository.forks_count > 0 && (
|
||||
<Badge variant="subtle" size="md" icon="i-ph:git-fork w-3 h-3">
|
||||
{selectedRepository.forks_count.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Select Branch
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
||||
className="w-full px-3 py-3 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-purple-500 shadow-sm"
|
||||
>
|
||||
{branches.map((branch) => (
|
||||
<option
|
||||
key={branch.name}
|
||||
value={branch.name}
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||
>
|
||||
{branch.name} {branch.default ? '(default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||
<span>Ready to import?</span>
|
||||
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleImport}
|
||||
className="w-full h-12 px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white transition-all duration-200 flex items-center gap-2 justify-center shadow-md"
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||
Import {selectedRepository.name}
|
||||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<RepositoryList
|
||||
repos={activeTab === 'my-repos' ? repositories : searchResults}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleRepoSelect}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
|
||||
{/* GitHub Auth Dialog */}
|
||||
<GitHubAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
|
||||
|
||||
{/* Repository Stats Dialog */}
|
||||
{currentStats && (
|
||||
<StatsDialog
|
||||
isOpen={showStatsDialog}
|
||||
onClose={() => setShowStatsDialog(false)}
|
||||
onConfirm={handleStatsConfirm}
|
||||
stats={currentStats}
|
||||
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
|
||||
/>
|
||||
)}
|
||||
</Dialog.Root>
|
||||
</RepositoryDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { RepositoryStats } from '~/types/GitHub';
|
||||
import { formatSize } from '~/utils/formatSize';
|
||||
import { RepositoryStats as RepoStats } from '~/components/ui';
|
||||
|
||||
interface StatsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
stats: RepositoryStats;
|
||||
isLargeRepo?: boolean;
|
||||
}
|
||||
|
||||
export function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500">
|
||||
<span className="i-ph:git-branch w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
Repository Overview
|
||||
</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
Review repository details before importing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 p-4 rounded-lg">
|
||||
<RepoStats stats={stats} />
|
||||
</div>
|
||||
|
||||
{isLargeRepo && (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
||||
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-yellow-800 dark:text-yellow-500">
|
||||
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
||||
and could impact performance.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark p-4 flex justify-end gap-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-b-lg">
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
Import Repository
|
||||
</motion.button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
95
app/components/@settings/tabs/connections/types/GitHub.ts
Normal file
95
app/components/@settings/tabs/connections/types/GitHub.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
public_gists: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
language: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
export interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
description: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
repo: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
payload: {
|
||||
action?: string;
|
||||
ref?: string;
|
||||
ref_type?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitHubLanguageStats {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
export interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
totalStars: number;
|
||||
totalForks: number;
|
||||
organizations: GitHubOrganization[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
}
|
||||
|
||||
export interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
}
|
||||
|
||||
export interface GitHubTokenInfo {
|
||||
token: string;
|
||||
scope: string[];
|
||||
avatar_url: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
followers: number;
|
||||
}
|
||||
|
||||
export interface GitHubRateLimits {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: Date;
|
||||
used: number;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
username: string;
|
||||
tokenInfo: GitHubTokenInfo | null;
|
||||
isConnected: boolean;
|
||||
isVerifying: boolean;
|
||||
isLoadingRepos: boolean;
|
||||
rateLimits?: GitHubRateLimits;
|
||||
}
|
||||
2110
app/components/@settings/tabs/debug/DebugTab.tsx
Normal file
2110
app/components/@settings/tabs/debug/DebugTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,281 +0,0 @@
|
||||
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 = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<ConnectionTestResult | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
|
||||
</div>
|
||||
<LoadingState message="Checking GitHub connection..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state for connection issues
|
||||
if (error && !connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
|
||||
</div>
|
||||
<ErrorState
|
||||
title="Connection Error"
|
||||
message={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
retryLabel="Reload Page"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected state
|
||||
if (!isConnected || !connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitHub Integration</h2>
|
||||
</div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Connect your GitHub account to enable advanced repository management features, statistics, and seamless
|
||||
integration.
|
||||
</p>
|
||||
<GitHubConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GitHubErrorBoundary>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between gap-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitHub Integration
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection?.rateLimit && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-bolt-elements-background-depth-1 rounded-lg text-xs">
|
||||
<div className="i-ph:cloud w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Manage your GitHub integration with advanced repository features and comprehensive statistics
|
||||
</p>
|
||||
|
||||
{/* Connection Test Results */}
|
||||
<ConnectionTestIndicator
|
||||
status={connectionTest?.status || null}
|
||||
message={connectionTest?.message}
|
||||
timestamp={connectionTest?.timestamp}
|
||||
/>
|
||||
|
||||
{/* Connection Component */}
|
||||
<GitHubConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
|
||||
|
||||
{/* User Profile */}
|
||||
{connection.user && <GitHubUserProfile user={connection.user} />}
|
||||
|
||||
{/* Stats Section */}
|
||||
<GitHubStats connection={connection} isExpanded={isStatsExpanded} onToggleExpanded={setIsStatsExpanded} />
|
||||
|
||||
{/* Repositories Section */}
|
||||
{stats?.repos && stats.repos.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="border-t border-bolt-elements-borderColor pt-6"
|
||||
>
|
||||
<Collapsible open={isReposExpanded} onOpenChange={setIsReposExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:folder w-4 h-4 text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
All Repositories ({stats.repos.length})
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={classNames(
|
||||
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||
isReposExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{(isReposExpanded ? stats.repos : stats.repos.slice(0, 12)).map((repo) => (
|
||||
<RepositoryCard
|
||||
key={repo.full_name}
|
||||
repository={repo}
|
||||
variant="detailed"
|
||||
showHealthScore
|
||||
showExtendedMetrics
|
||||
onSelect={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stats.repos.length > 12 && !isReposExpanded && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsReposExpanded(true)}
|
||||
className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
>
|
||||
Show {stats.repos.length - 12} more repositories
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Stats Error State */}
|
||||
{statsError && !stats && (
|
||||
<ErrorState
|
||||
title="Failed to Load Statistics"
|
||||
message={statsError}
|
||||
onRetry={() => window.location.reload()}
|
||||
retryLabel="Retry"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Loading State */}
|
||||
{isStatsLoading && !stats && (
|
||||
<GitHubProgressiveLoader
|
||||
isLoading={isStatsLoading}
|
||||
loadingMessage="Loading GitHub statistics..."
|
||||
showProgress={true}
|
||||
progressSteps={[
|
||||
{ key: 'user', label: 'Fetching user info', completed: !!connection?.user, loading: !connection?.user },
|
||||
{ key: 'repos', label: 'Loading repositories', completed: false, loading: true },
|
||||
{ key: 'stats', label: 'Calculating statistics', completed: false },
|
||||
{ key: 'cache', label: 'Updating cache', completed: false },
|
||||
]}
|
||||
>
|
||||
<div />
|
||||
</GitHubProgressiveLoader>
|
||||
)}
|
||||
|
||||
{/* Cache Management Section - Only show when connected */}
|
||||
{isConnected && connection && (
|
||||
<div className="mt-8 pt-6 border-t border-bolt-elements-borderColor">
|
||||
<GitHubCacheManager showStats={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GitHubErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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 (
|
||||
<Dialog.Root open={isOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-[200]" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[201] w-full max-w-md"
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg shadow-lg"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Connect to GitHub</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 rounded-md hover:bg-bolt-elements-item-backgroundActive/10"
|
||||
>
|
||||
<div className="i-ph:x w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You need a GitHub token to deploy repositories.
|
||||
</p>
|
||||
<p>Required scopes: repo, read:org, read:user</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
||||
<select
|
||||
value={tokenType}
|
||||
onChange={(e) => setTokenType(e.target.value as 'classic' | 'fine-grained')}
|
||||
disabled={isConnecting}
|
||||
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',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
{tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
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<CacheEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastClearTime, setLastClearTime] = useState<number | null>(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 (
|
||||
<div
|
||||
className={classNames(
|
||||
'space-y-4 p-4 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-bolt-elements-item-contentAccent" />
|
||||
<h3 className="text-sm font-medium text-bolt-elements-textPrimary">GitHub Cache Management</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refreshCacheData} disabled={isLoading}>
|
||||
<RefreshCw className={classNames('w-3 h-3', isLoading ? 'animate-spin' : '')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<HardDrive className="w-3 h-3 text-bolt-elements-textSecondary" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary">Total Size</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-bolt-elements-textPrimary">
|
||||
{CacheManagerService.formatSize(cacheStats.totalSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="w-3 h-3 text-bolt-elements-textSecondary" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary">Entries</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-bolt-elements-textPrimary">{cacheStats.totalEntries}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-3 h-3 text-bolt-elements-textSecondary" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary">Oldest</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
{cacheStats.oldestEntry ? new Date(cacheStats.oldestEntry).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-background-depth-2 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-3 h-3 text-bolt-elements-textSecondary" />
|
||||
<span className="text-xs font-medium text-bolt-elements-textSecondary">Status</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{cacheStats.totalEntries > 0 ? 'Active' : 'Empty'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cacheEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-bolt-elements-textSecondary">
|
||||
Cache Entries ({cacheEntries.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{cacheEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="flex items-center justify-between p-2 bg-bolt-elements-background-depth-2 rounded border border-bolt-elements-borderColor"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-bolt-elements-textPrimary truncate">
|
||||
{entry.key.replace('github_', '')}
|
||||
</p>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
{CacheManagerService.formatSize(entry.size)} • {new Date(entry.lastAccessed).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleClearSpecific(entry.key)}
|
||||
disabled={isLoading}
|
||||
className="ml-2"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-bolt-elements-borderColor">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearExpired}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span className="text-xs">Clear Expired</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCompactCache}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<span className="text-xs">Compact</span>
|
||||
</Button>
|
||||
|
||||
{cacheEntries.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1 text-red-600 hover:text-red-700 border-red-200 hover:border-red-300"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<span className="text-xs">Clear All</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastClearTime && (
|
||||
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded text-xs text-green-700 dark:text-green-400">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Cache cleared successfully at {new Date(lastClearTime).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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 (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading connection...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{!isConnected && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set the{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_ACCESS_TOKEN
|
||||
</code>{' '}
|
||||
environment variable to connect automatically.
|
||||
</p>
|
||||
<p>
|
||||
For fine-grained tokens, also set{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITHUB_TOKEN_TYPE=fine-grained
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleConnect} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
Token Type
|
||||
</label>
|
||||
<select
|
||||
value={tokenType}
|
||||
onChange={(e) => setTokenType(e.target.value as 'classic' | 'fine-grained')}
|
||||
disabled={isConnecting || isConnected}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||
{tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!isConnected ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#303030] text-white',
|
||||
'hover:bg-[#5E41D0] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={disconnect}
|
||||
type="button"
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:layout w-4 h-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
disabled={connectionTest?.status === 'testing'}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{connectionTest?.status === 'testing' ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">GitHub Integration Error</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4 max-w-md">
|
||||
Something went wrong while loading GitHub data. This could be due to network issues, API limits, or a
|
||||
temporary problem.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<details className="text-xs text-bolt-elements-textTertiary mb-4">
|
||||
<summary className="cursor-pointer hover:text-bolt-elements-textSecondary">Show error details</summary>
|
||||
<pre className="mt-2 p-2 bg-bolt-elements-background-depth-2 rounded text-left overflow-auto">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Higher-order component for wrapping components with error boundary
|
||||
export function withGitHubErrorBoundary<P extends object>(component: React.ComponentType<P>) {
|
||||
return function WrappedComponent(props: P) {
|
||||
return <GitHubErrorBoundary>{React.createElement(component, props)}</GitHubErrorBoundary>;
|
||||
};
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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 (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-8', className)}>
|
||||
<div className="relative mb-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bolt-elements-item-contentAccent" />
|
||||
{showProgress && progress > 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-bolt-elements-item-contentAccent">{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{loadingMessage}</p>
|
||||
|
||||
{showProgress && progressSteps.length > 0 && (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-bolt-elements-background-depth-2 rounded-full h-2 mb-3">
|
||||
<motion.div
|
||||
className="bg-bolt-elements-item-contentAccent h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps toggle */}
|
||||
<button
|
||||
onClick={handleToggleExpanded}
|
||||
className="flex items-center justify-center gap-2 text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<span>Show details</span>
|
||||
<ChevronDown
|
||||
className={classNames(
|
||||
'w-3 h-3 transform transition-transform duration-200',
|
||||
isExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Progress steps */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-3 space-y-2 overflow-hidden"
|
||||
>
|
||||
{progressSteps.map((step) => (
|
||||
<div key={step.key} className="flex items-center gap-2 text-xs">
|
||||
{step.error ? (
|
||||
<AlertCircle className="w-3 h-3 text-red-500 flex-shrink-0" />
|
||||
) : step.completed ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||||
) : step.loading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-bolt-elements-item-contentAccent flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-full border border-bolt-elements-borderColor flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
step.error
|
||||
? 'text-red-500'
|
||||
: step.completed
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: step.loading
|
||||
? 'text-bolt-elements-textPrimary'
|
||||
: 'text-bolt-elements-textSecondary',
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-8 text-center space-y-4', className)}>
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-1">Failed to Load</h3>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mb-4 max-w-sm">{error}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} className="text-xs">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} className="text-xs">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state - render children with optional refresh indicator
|
||||
return (
|
||||
<div className={classNames('relative', className)}>
|
||||
{isRefreshing && (
|
||||
<div className="absolute top-0 right-0 z-10">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg shadow-sm">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{refreshingMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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 (
|
||||
<a
|
||||
key={repo.name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info" />
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
{repo.private && (
|
||||
<div className="i-ph:lock w-3 h-3 text-bolt-elements-textTertiary" title="Private repository" />
|
||||
)}
|
||||
{repo.fork && (
|
||||
<div className="i-ph:git-fork w-3 h-3 text-bolt-elements-textTertiary" title="Forked repository" />
|
||||
)}
|
||||
{repo.archived && (
|
||||
<div className="i-ph:archive w-3 h-3 text-bolt-elements-textTertiary" title="Archived repository" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1" title="Primary Language">
|
||||
<div className="w-2 h-2 rounded-full bg-current opacity-60" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository topics/tags */}
|
||||
{repo.topics && repo.topics.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{repo.topics.slice(0, 3).map((topic) => (
|
||||
<span
|
||||
key={topic}
|
||||
className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
title={`Topic: ${topic}`}
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
{repo.topics.length > 3 && (
|
||||
<span className="text-bolt-elements-textTertiary">+{repo.topics.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository size if available */}
|
||||
{repo.size && (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">Size: {(repo.size / 1024).toFixed(1)} MB</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section with Clone button positioned at bottom right */}
|
||||
<div className="flex items-center justify-between pt-3 mt-auto">
|
||||
<span className="flex items-center gap-1 text-xs text-bolt-elements-textSecondary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
{onClone && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClone(repo);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
title="Clone repository"
|
||||
>
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
Clone
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
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<SortOption>('updated');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedRepo, setSelectedRepo] = useState<GitHubRepoInfo | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="text-center p-8">
|
||||
<p className="text-bolt-elements-textSecondary mb-4">Please connect to GitHub first to browse repositories</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Refresh Connection
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isStatsLoading && !stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-bolt-elements-borderColorActive border-t-transparent rounded-full" />
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Loading repositories...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repositories.length) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<GitBranch className="w-12 h-12 text-bolt-elements-textTertiary mx-auto mb-4" />
|
||||
<p className="text-bolt-elements-textSecondary mb-4">No repositories found</p>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={classNames('space-y-6', className)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Select Repository to Clone</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
{filteredRepositories.length} of {repositories.length} repositories
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={classNames('w-4 h-4', { 'animate-spin': isRefreshing })} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && repositories.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-700">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">Warning: {error}. Showing cached data.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="stars">Most starred</option>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="created">Recently created</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<select
|
||||
value={filterBy}
|
||||
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
|
||||
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
>
|
||||
<option value="all">All repositories</option>
|
||||
<option value="own">Own repositories</option>
|
||||
<option value="forks">Forked repositories</option>
|
||||
<option value="archived">Archived repositories</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Grid */}
|
||||
{currentRepositories.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{currentRepositories.map((repo) => (
|
||||
<GitHubRepositoryCard key={repo.id} repo={repo} onClone={() => handleCloneRepository(repo)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">
|
||||
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
|
||||
{Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
|
||||
repositories
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary px-3">
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-bolt-elements-textSecondary">No repositories found matching your search criteria.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch Selector Modal */}
|
||||
{selectedRepo && (
|
||||
<BranchSelector
|
||||
provider="github"
|
||||
repoOwner={selectedRepo.full_name.split('/')[0]}
|
||||
repoName={selectedRepo.full_name.split('/')[1]}
|
||||
token={connection?.token || ''}
|
||||
defaultBranch={selectedRepo.default_branch}
|
||||
onBranchSelect={handleBranchSelect}
|
||||
onClose={handleCloseBranchSelector}
|
||||
isOpen={isBranchSelectorOpen}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
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 (
|
||||
<GitHubErrorBoundary>
|
||||
<GitHubStatsContent
|
||||
stats={stats}
|
||||
isLoading={isLoading}
|
||||
isRefreshing={isRefreshing}
|
||||
refreshStats={refreshStats}
|
||||
isStale={isStale}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
/>
|
||||
</GitHubErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubStatsContent({
|
||||
stats,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refreshStats,
|
||||
isStale,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
}: {
|
||||
stats: GitHubStatsType | null;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
refreshStats: () => Promise<void>;
|
||||
isStale: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: (expanded: boolean) => void;
|
||||
}) {
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading GitHub stats...</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-bolt-elements-textSecondary">No stats available</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor pt-6">
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
GitHub Stats
|
||||
{isStale && <span className="text-bolt-elements-textTertiary ml-1">(Stale)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refreshStats();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-3 h-3 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:arrows-clockwise w-3 h-3" />
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||
isExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
{stats.mostUsedLanguages && stats.mostUsedLanguages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.mostUsedLanguages.slice(0, 15).map(({ language, bytes, repos }) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
|
||||
title={`${language}: ${(bytes / 1024 / 1024).toFixed(2)}MB across ${repos} repos`}
|
||||
>
|
||||
{language} ({repos})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary">
|
||||
Based on actual codebase size across repositories
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GitHub Overview Summary */}
|
||||
<div className="mb-6 p-4 bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">GitHub Overview</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
|
||||
{(stats.publicRepos || 0) + (stats.privateRepos || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary">Total Repositories</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-bolt-elements-textPrimary">{stats.totalBranches || 0}</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary">Total Branches</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
|
||||
{stats.organizations?.length || 0}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary">Organizations</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-bolt-elements-textPrimary">
|
||||
{Object.keys(stats.languages).length}
|
||||
</div>
|
||||
<div className="text-xs text-bolt-elements-textSecondary">Languages Used</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Summary */}
|
||||
<div className="mb-6">
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Activity Summary</h5>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
|
||||
{stat.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations Section */}
|
||||
{stats.organizations && stats.organizations.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Organizations</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{stats.organizations.map((org) => (
|
||||
<a
|
||||
key={org.login}
|
||||
href={org.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive dark:hover:border-bolt-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<img
|
||||
src={org.avatar_url}
|
||||
alt={org.login}
|
||||
className="w-8 h-8 rounded-full border border-bolt-elements-borderColor"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h6 className="text-sm font-medium text-bolt-elements-textPrimary truncate">
|
||||
{org.name || org.login}
|
||||
</h6>
|
||||
<p className="text-xs text-bolt-elements-textSecondary truncate">{org.login}</p>
|
||||
{org.description && (
|
||||
<p className="text-xs text-bolt-elements-textTertiary truncate">{org.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
|
||||
{org.public_repos && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:folder w-3 h-3" />
|
||||
{org.public_repos}
|
||||
</span>
|
||||
)}
|
||||
{org.followers && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:users w-3 h-3" />
|
||||
{org.followers}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="pt-2 border-t border-bolt-elements-borderColor">
|
||||
<span className="text-xs text-bolt-elements-textSecondary">
|
||||
Last updated: {stats.lastUpdated ? new Date(stats.lastUpdated).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||
|
||||
interface GitHubUserProfileProps {
|
||||
user: GitHubUserResponse;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitHubUserProfile({ user, className = '' }: GitHubUserProfileProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg ${className}`}
|
||||
>
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.login}
|
||||
className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent dark:border-bolt-elements-item-contentAccent"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
{user.name || user.login}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">@{user.login}</p>
|
||||
{user.bio && (
|
||||
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary mt-1">
|
||||
{user.bio}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:users w-3 h-3" />
|
||||
{user.followers} followers
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:folder w-3 h-3" />
|
||||
{user.public_repos} public repos
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:file-text w-3 h-3" />
|
||||
{user.public_gists} gists
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
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 (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className={classNames('animate-spin mb-2', sizeClasses[size])} />
|
||||
<p className={classNames('text-bolt-elements-textSecondary', textSizeClasses[size])}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
|
||||
<AlertCircle className={classNames('text-red-500 mb-2', sizeClasses[size])} />
|
||||
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
|
||||
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
|
||||
<CheckCircle className={classNames('text-green-500 mb-2', sizeClasses[size])} />
|
||||
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
|
||||
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
|
||||
{onAction && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitHubConnectionRequiredProps {
|
||||
onConnect?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitHubConnectionRequired({ onConnect, className = '' }: GitHubConnectionRequiredProps) {
|
||||
return (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
<Github className="w-12 h-12 text-bolt-elements-textTertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">GitHub Connection Required</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-6 max-w-md">
|
||||
Please connect your GitHub account to access this feature. You'll be able to browse repositories, push code, and
|
||||
manage your GitHub integration.
|
||||
</p>
|
||||
{onConnect && (
|
||||
<button
|
||||
onClick={onConnect}
|
||||
className="px-6 py-3 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
Connect GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames('flex flex-col items-center justify-center py-8 text-center', className)}>
|
||||
{React.createElement(icon, { className: classNames('text-blue-500 mb-2', sizeClasses[size]) })}
|
||||
<h3 className={classNames('font-medium text-bolt-elements-textPrimary mb-1', textSizeClasses[size])}>{title}</h3>
|
||||
<p className={classNames('text-bolt-elements-textSecondary mb-4', textSizeClasses[size])}>{message}</p>
|
||||
{onAction && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="px-4 py-2 bg-bolt-elements-item-contentAccent text-white rounded-lg hover:bg-bolt-elements-item-contentAccent/90 transition-colors"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />;
|
||||
case 'testing':
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-600 dark:text-blue-400" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-gray-600 dark:text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classNames(`p-4 rounded-lg border ${getStatusColor()}`, className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<span className={classNames('text-sm font-medium', getStatusTextColor())}>{message || status}</span>
|
||||
</div>
|
||||
{timestamp && <p className="text-xs text-gray-500 mt-1">{new Date(timestamp).toLocaleString()}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
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 (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={classNames(
|
||||
'w-full text-left p-3 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive hover:bg-bolt-elements-background-depth-1 transition-all duration-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{repository.name}</h4>
|
||||
{repository.private && <Lock className="w-3 h-3 text-bolt-elements-textTertiary" />}
|
||||
{repository.fork && <GitFork className="w-3 h-3 text-bolt-elements-textTertiary" />}
|
||||
{repository.archived && <Archive className="w-3 h-3 text-bolt-elements-textTertiary" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{repository.stargazers_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-3 h-3" />
|
||||
{repository.forks_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repository.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary mb-2 line-clamp-2">{repository.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textTertiary">
|
||||
{repository.language && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-current opacity-60" />
|
||||
{repository.language}
|
||||
</span>
|
||||
)}
|
||||
{repository.size && <span>{formatSize(repository.size * 1024)}</span>}
|
||||
</div>
|
||||
|
||||
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimeAgo()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Component
|
||||
{...interactiveProps}
|
||||
className={classNames(
|
||||
'block p-4 rounded-lg bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor relative',
|
||||
interactiveProps.className,
|
||||
)}
|
||||
>
|
||||
{/* Repository Health Indicator */}
|
||||
{variant === 'detailed' && (
|
||||
<div
|
||||
className={`absolute top-2 right-2 w-2 h-2 rounded-full ${getHealthIndicatorColor()}`}
|
||||
title={`Repository Health: ${getHealthTitle()}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-bolt-elements-icon-tertiary" />
|
||||
<h5
|
||||
className={classNames(
|
||||
'text-sm font-medium text-bolt-elements-textPrimary',
|
||||
onSelect && 'group-hover:text-bolt-elements-item-contentAccent transition-colors',
|
||||
)}
|
||||
>
|
||||
{repository.name}
|
||||
</h5>
|
||||
{repository.fork && (
|
||||
<span title="Forked repository">
|
||||
<GitFork className="w-3 h-3 text-bolt-elements-textTertiary" />
|
||||
</span>
|
||||
)}
|
||||
{repository.archived && (
|
||||
<span title="Archived repository">
|
||||
<Archive className="w-3 h-3 text-bolt-elements-textTertiary" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<Star className="w-3.5 h-3.5 text-bolt-elements-icon-warning" />
|
||||
{repository.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<GitFork className="w-3.5 h-3.5 text-bolt-elements-icon-info" />
|
||||
{repository.forks_count.toLocaleString()}
|
||||
</span>
|
||||
{showExtendedMetrics && repository.issues_count !== undefined && (
|
||||
<span className="flex items-center gap-1" title="Open Issues">
|
||||
<Circle className="w-3.5 h-3.5 text-bolt-elements-icon-error" />
|
||||
{repository.issues_count}
|
||||
</span>
|
||||
)}
|
||||
{showExtendedMetrics && repository.pull_requests_count !== undefined && (
|
||||
<span className="flex items-center gap-1" title="Pull Requests">
|
||||
<GitPullRequest className="w-3.5 h-3.5 text-bolt-elements-icon-success" />
|
||||
{repository.pull_requests_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{repository.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repository.description}</p>
|
||||
)}
|
||||
|
||||
{/* Repository metrics bar */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{repository.license && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary">
|
||||
{repository.license.spdx_id || repository.license.name}
|
||||
</span>
|
||||
)}
|
||||
{repository.topics &&
|
||||
repository.topics.slice(0, 2).map((topic) => (
|
||||
<span
|
||||
key={topic}
|
||||
className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
{repository.archived && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
{repository.fork && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
{repository.default_branch}
|
||||
</span>
|
||||
{showExtendedMetrics && repository.branches_count && (
|
||||
<span className="flex items-center gap-1" title="Total Branches">
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
{repository.branches_count}
|
||||
</span>
|
||||
)}
|
||||
{showExtendedMetrics && repository.contributors_count && (
|
||||
<span className="flex items-center gap-1" title="Contributors">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
{repository.contributors_count}
|
||||
</span>
|
||||
)}
|
||||
{repository.size && (
|
||||
<span className="flex items-center gap-1" title="Size">
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{(repository.size / 1024).toFixed(1)}MB
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatTimeAgo()}
|
||||
</span>
|
||||
{repository.topics && repository.topics.length > 0 && (
|
||||
<span className="flex items-center gap-1" title={`Topics: ${repository.topics.join(', ')}`}>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{repository.topics.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Repository Health Score */}
|
||||
{health && (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title={`Health Score: ${health.percentage}% (${health.score}/${health.maxScore})`}
|
||||
>
|
||||
<Heart className={`w-3.5 h-3.5 ${health.color}`} />
|
||||
<span className={`text-xs font-medium ${health.color}`}>{health.percentage}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSelect && (
|
||||
<span
|
||||
className={classNames(
|
||||
'flex items-center gap-1 ml-2 transition-colors',
|
||||
'group-hover:text-bolt-elements-item-contentAccent',
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export { RepositoryCard } from './RepositoryCard';
|
||||
|
||||
// GitHubDialog components not yet implemented
|
||||
export {
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
SuccessState,
|
||||
GitHubConnectionRequired,
|
||||
InformationState,
|
||||
ConnectionTestIndicator,
|
||||
} from './GitHubStateIndicators';
|
||||
@@ -1,305 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useGitLabConnection } from '~/lib/hooks';
|
||||
import GitLabConnection from './components/GitLabConnection';
|
||||
import { StatsDisplay } from './components/StatsDisplay';
|
||||
import { RepositoryList } from './components/RepositoryList';
|
||||
|
||||
// GitLab logo SVG component
|
||||
const GitLabLogo = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface ConnectionTestResult {
|
||||
status: 'success' | 'error' | 'testing';
|
||||
message: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export default function GitLabTab() {
|
||||
const { connection, isConnected, isLoading, error, testConnection, refreshStats } = useGitLabConnection();
|
||||
const [connectionTest, setConnectionTest] = useState<ConnectionTestResult | null>(null);
|
||||
const [isRefreshingStats, setIsRefreshingStats] = useState(false);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!connection?.user) {
|
||||
setConnectionTest({
|
||||
status: 'error',
|
||||
message: 'No connection established',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionTest({
|
||||
status: 'testing',
|
||||
message: 'Testing connection...',
|
||||
});
|
||||
|
||||
try {
|
||||
const isValid = await testConnection();
|
||||
|
||||
if (isValid) {
|
||||
setConnectionTest({
|
||||
status: 'success',
|
||||
message: `Connected successfully as ${connection.user.username}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
setConnectionTest({
|
||||
status: 'error',
|
||||
message: 'Connection test failed',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionTest({
|
||||
status: 'error',
|
||||
message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state for initial connection check
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitLabLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state for connection issues
|
||||
if (error && !connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitLabLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected state
|
||||
if (!isConnected || !connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitLabLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">GitLab Integration</h2>
|
||||
</div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Connect your GitLab account to enable advanced repository management features, statistics, and seamless
|
||||
integration.
|
||||
</p>
|
||||
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between gap-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitLabLogo />
|
||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
GitLab Integration
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection?.rateLimit && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-bolt-elements-background-depth-1 rounded-lg text-xs">
|
||||
<div className="i-ph:cloud w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
API: {connection.rateLimit.remaining}/{connection.rateLimit.limit}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||
Manage your GitLab integration with advanced repository features and comprehensive statistics
|
||||
</p>
|
||||
|
||||
{/* Connection Test Results */}
|
||||
{connectionTest && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${
|
||||
connectionTest.status === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: connectionTest.status === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-4 h-4 ${
|
||||
connectionTest.status === 'success'
|
||||
? 'text-green-600'
|
||||
: connectionTest.status === 'error'
|
||||
? 'text-red-600'
|
||||
: 'text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{connectionTest.status === 'success' ? (
|
||||
<div className="i-ph:check-circle" />
|
||||
) : connectionTest.status === 'error' ? (
|
||||
<div className="i-ph:x-circle" />
|
||||
) : (
|
||||
<div className="i-ph:spinner animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
connectionTest.status === 'success'
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: connectionTest.status === 'error'
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}
|
||||
>
|
||||
{connectionTest.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitLab Connection Component */}
|
||||
<GitLabConnection connectionTest={connectionTest} onTestConnection={handleTestConnection} />
|
||||
|
||||
{/* User Profile Section */}
|
||||
{connection?.user && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="border-t border-bolt-elements-borderColor pt-6"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 bg-bolt-elements-background-depth-1 rounded-lg">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-bolt-elements-item-contentAccent flex items-center justify-center bg-bolt-elements-background-depth-2 overflow-hidden">
|
||||
{connection.user.avatar_url &&
|
||||
connection.user.avatar_url !== 'null' &&
|
||||
connection.user.avatar_url !== '' ? (
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.username}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
|
||||
const parent = target.parentElement;
|
||||
|
||||
if (parent) {
|
||||
parent.innerHTML = (connection.user?.name || connection.user?.username || 'U')
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
parent.classList.add(
|
||||
'text-white',
|
||||
'font-semibold',
|
||||
'text-sm',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-full bg-bolt-elements-item-contentAccent flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(connection.user?.name || connection.user?.username || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user?.name || connection.user?.username}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* GitLab Stats Section */}
|
||||
{connection?.stats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="border-t border-bolt-elements-borderColor pt-6"
|
||||
>
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary mb-4">Statistics</h3>
|
||||
<StatsDisplay
|
||||
stats={connection.stats}
|
||||
onRefresh={async () => {
|
||||
setIsRefreshingStats(true);
|
||||
|
||||
try {
|
||||
await refreshStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh stats:', error);
|
||||
} finally {
|
||||
setIsRefreshingStats(false);
|
||||
}
|
||||
}}
|
||||
isRefreshing={isRefreshingStats}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* GitLab Repositories Section */}
|
||||
{connection?.stats?.projects && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="border-t border-bolt-elements-borderColor pt-6"
|
||||
>
|
||||
<RepositoryList
|
||||
repositories={connection.stats.projects}
|
||||
onRefresh={async () => {
|
||||
setIsRefreshingStats(true);
|
||||
|
||||
try {
|
||||
await refreshStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh repositories:', error);
|
||||
} finally {
|
||||
setIsRefreshingStats(false);
|
||||
}
|
||||
}}
|
||||
isRefreshing={isRefreshingStats}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useGitLabConnection } from '~/lib/hooks';
|
||||
|
||||
interface GitLabAuthDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitLabAuthDialog({ isOpen, onClose }: GitLabAuthDialogProps) {
|
||||
const { isConnecting, error, connect } = useGitLabConnection();
|
||||
const [token, setToken] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your GitLab access token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await connect(token, gitlabUrl);
|
||||
toast.success('Successfully connected to GitLab!');
|
||||
setToken('');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// Error handling is done in the hook
|
||||
console.error('GitLab connect failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[10000]" />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px]"
|
||||
>
|
||||
<Dialog.Content
|
||||
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||
aria-describedby="gitlab-auth-description"
|
||||
>
|
||||
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-4">
|
||||
Connect to GitLab
|
||||
</Dialog.Title>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/10 flex items-center justify-center text-orange-500">
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||
GitLab Connection
|
||||
</h3>
|
||||
<p
|
||||
id="gitlab-auth-description"
|
||||
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||
>
|
||||
Connect your GitLab account to deploy your projects
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
GitLab URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={gitlabUrl}
|
||||
onChange={(e) => setGitlabUrl(e.target.value)}
|
||||
disabled={isConnecting}
|
||||
placeholder="https://gitlab.com"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
|
||||
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
|
||||
'focus:outline-none focus:ring-2 focus:ring-orange-500',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||
Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={isConnecting}
|
||||
placeholder="Enter your GitLab access token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
|
||||
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark',
|
||||
'placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark',
|
||||
'focus:outline-none focus:ring-2 focus:ring-orange-500',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
required
|
||||
/>
|
||||
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||
<a
|
||||
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-3 h-3" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Required scopes: api, read_repository</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm inline-flex items-center gap-2',
|
||||
'bg-orange-500 text-white hover:bg-orange-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
whileHover={!isConnecting && token.trim() ? { scale: 1.02 } : {}}
|
||||
whileTap={!isConnecting && token.trim() ? { scale: 0.98 } : {}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect to GitLab
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { useGitLabConnection } from '~/lib/hooks';
|
||||
|
||||
interface ConnectionTestResult {
|
||||
status: 'success' | 'error' | 'testing';
|
||||
message: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface GitLabConnectionProps {
|
||||
connectionTest: ConnectionTestResult | null;
|
||||
onTestConnection: () => void;
|
||||
}
|
||||
|
||||
export default function GitLabConnection({ connectionTest, onTestConnection }: GitLabConnectionProps) {
|
||||
const { isConnected, isConnecting, connection, error, connect, disconnect } = useGitLabConnection();
|
||||
|
||||
const [token, setToken] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('https://gitlab.com');
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
console.log('GitLab connect attempt:', {
|
||||
token: token ? `${token.substring(0, 10)}...` : 'empty',
|
||||
gitlabUrl,
|
||||
tokenLength: token.length,
|
||||
});
|
||||
|
||||
if (!token.trim()) {
|
||||
console.log('Token is empty, not attempting connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Calling connect function...');
|
||||
await connect(token, gitlabUrl);
|
||||
console.log('Connect function completed successfully');
|
||||
setToken(''); // Clear token on successful connection
|
||||
} catch (error) {
|
||||
console.error('GitLab connect failed:', error);
|
||||
|
||||
// Error handling is done in the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
disconnect();
|
||||
toast.success('Disconnected from GitLab');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-bolt-elements-background border border-bolt-elements-borderColor rounded-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 text-orange-600">
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitLab Connection</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 p-3 rounded-lg mb-4">
|
||||
<p className="flex items-center gap-1 mb-1">
|
||||
<span className="i-ph:lightbulb w-3.5 h-3.5 text-bolt-elements-icon-success" />
|
||||
<span className="font-medium">Tip:</span> You can also set the{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">VITE_GITLAB_ACCESS_TOKEN</code>{' '}
|
||||
environment variable to connect automatically.
|
||||
</p>
|
||||
<p>
|
||||
For self-hosted GitLab instances, also set{' '}
|
||||
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 rounded">
|
||||
VITE_GITLAB_URL=https://your-gitlab-instance.com
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitLab URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gitlabUrl}
|
||||
onChange={(e) => setGitlabUrl(e.target.value)}
|
||||
disabled={isConnecting || isConnected}
|
||||
placeholder="https://gitlab.com"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={isConnecting || isConnected}
|
||||
placeholder="Enter your GitLab access token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`${gitlabUrl}/-/user_settings/personal_access_tokens`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Required scopes: api, read_repository</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-700">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!isConnected ? (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting || !token.trim()}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#FC6D26] text-white',
|
||||
'hover:bg-[#E24329] hover:text-white',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200',
|
||||
'transform active:scale-95',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log('Manual test:', { token: token ? `${token.substring(0, 10)}...` : 'empty', gitlabUrl })
|
||||
}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-gray-500 text-white hover:bg-gray-600"
|
||||
>
|
||||
Test Values
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to GitLab
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`${connection?.gitlabUrl || 'https://gitlab.com'}/dashboard`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:layout w-4 h-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
disabled={connectionTest?.status === 'testing'}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
{connectionTest?.status === 'testing' ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { BranchSelector } from '~/components/ui/BranchSelector';
|
||||
import { RepositoryCard } from './RepositoryCard';
|
||||
import type { GitLabProjectInfo } from '~/types/GitLab';
|
||||
import { useGitLabConnection } from '~/lib/hooks';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Search, RefreshCw, GitBranch, Calendar, Filter } from 'lucide-react';
|
||||
|
||||
interface GitLabRepositorySelectorProps {
|
||||
onClone?: (repoUrl: string, branch?: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SortOption = 'updated' | 'stars' | 'name' | 'created';
|
||||
type FilterOption = 'all' | 'owned' | 'member';
|
||||
|
||||
export function GitLabRepositorySelector({ onClone, className }: GitLabRepositorySelectorProps) {
|
||||
const { connection, isConnected } = useGitLabConnection();
|
||||
const [repositories, setRepositories] = useState<GitLabProjectInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('updated');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedRepo, setSelectedRepo] = useState<GitLabProjectInfo | null>(null);
|
||||
const [isBranchSelectorOpen, setIsBranchSelectorOpen] = useState(false);
|
||||
|
||||
const REPOS_PER_PAGE = 12;
|
||||
|
||||
// Fetch repositories
|
||||
const fetchRepositories = async (refresh = false) => {
|
||||
if (!isConnected || !connection?.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingState = refresh ? setIsRefreshing : setIsLoading;
|
||||
loadingState(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/gitlab-projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: connection.token,
|
||||
gitlabUrl: connection.gitlabUrl || 'https://gitlab.com',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: any = await response.json().catch(() => ({ error: 'Failed to fetch repositories' }));
|
||||
throw new Error(errorData.error || 'Failed to fetch repositories');
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
setRepositories(data.projects || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch GitLab repositories:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
|
||||
|
||||
// Fallback to empty array on error
|
||||
setRepositories([]);
|
||||
} finally {
|
||||
loadingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and search repositories
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!repositories) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = repositories.filter((repo: GitLabProjectInfo) => {
|
||||
// Search filter
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
// Type filter
|
||||
let matchesFilter = true;
|
||||
|
||||
switch (filterBy) {
|
||||
case 'owned':
|
||||
// This would need owner information from the API response
|
||||
matchesFilter = true; // For now, show all
|
||||
break;
|
||||
case 'member':
|
||||
// This would need member information from the API response
|
||||
matchesFilter = true; // For now, show all
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
matchesFilter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
// Sort repositories
|
||||
filtered.sort((a: GitLabProjectInfo, b: GitLabProjectInfo) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'stars':
|
||||
return b.star_count - a.star_count;
|
||||
case 'created':
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); // Using updated_at as proxy
|
||||
case 'updated':
|
||||
default:
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [repositories, searchQuery, sortBy, filterBy]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredRepositories.length / REPOS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * REPOS_PER_PAGE;
|
||||
const currentRepositories = filteredRepositories.slice(startIndex, startIndex + REPOS_PER_PAGE);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchRepositories(true);
|
||||
};
|
||||
|
||||
const handleCloneRepository = (repo: GitLabProjectInfo) => {
|
||||
setSelectedRepo(repo);
|
||||
setIsBranchSelectorOpen(true);
|
||||
};
|
||||
|
||||
const handleBranchSelect = (branch: string) => {
|
||||
if (onClone && selectedRepo) {
|
||||
onClone(selectedRepo.http_url_to_repo, branch);
|
||||
}
|
||||
|
||||
setSelectedRepo(null);
|
||||
};
|
||||
|
||||
const handleCloseBranchSelector = () => {
|
||||
setIsBranchSelectorOpen(false);
|
||||
setSelectedRepo(null);
|
||||
};
|
||||
|
||||
// Reset to first page when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, sortBy, filterBy]);
|
||||
|
||||
// Fetch repositories when connection is ready
|
||||
useEffect(() => {
|
||||
if (isConnected && connection?.token) {
|
||||
fetchRepositories();
|
||||
}
|
||||
}, [isConnected, connection?.token]);
|
||||
|
||||
if (!isConnected || !connection) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<p className="text-bolt-elements-textSecondary mb-4">Please connect to GitLab first to browse repositories</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Refresh Connection
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !repositories.length) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<div className="text-red-500 mb-4">
|
||||
<GitBranch className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="font-medium">Failed to load repositories</p>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mt-1">{error}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !repositories.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-bolt-elements-borderColorActive border-t-transparent rounded-full" />
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Loading repositories...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repositories.length && !isLoading) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<GitBranch className="w-12 h-12 text-bolt-elements-textTertiary mx-auto mb-4" />
|
||||
<p className="text-bolt-elements-textSecondary mb-4">No repositories found</p>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={classNames('w-4 h-4 mr-2', { 'animate-spin': isRefreshing })} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={classNames('space-y-6', className)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Select Repository to Clone</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
{filteredRepositories.length} of {repositories.length} repositories
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={classNames('w-4 h-4', { 'animate-spin': isRefreshing })} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && repositories.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-700">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">Warning: {error}. Showing cached data.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="stars">Most starred</option>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="created">Recently created</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-bolt-elements-textTertiary" />
|
||||
<select
|
||||
value={filterBy}
|
||||
onChange={(e) => setFilterBy(e.target.value as FilterOption)}
|
||||
className="px-3 py-2 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
>
|
||||
<option value="all">All repositories</option>
|
||||
<option value="owned">Owned repositories</option>
|
||||
<option value="member">Member repositories</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Grid */}
|
||||
{currentRepositories.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{currentRepositories.map((repo) => (
|
||||
<div key={repo.id} className="relative">
|
||||
<RepositoryCard repo={repo} onClone={() => handleCloneRepository(repo)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">
|
||||
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
|
||||
{Math.min(startIndex + REPOS_PER_PAGE, filteredRepositories.length)} of {filteredRepositories.length}{' '}
|
||||
repositories
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary px-3">
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-bolt-elements-textSecondary">No repositories found matching your search criteria.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch Selector Modal */}
|
||||
{selectedRepo && (
|
||||
<BranchSelector
|
||||
provider="gitlab"
|
||||
repoOwner={selectedRepo.path_with_namespace.split('/')[0]}
|
||||
repoName={selectedRepo.path_with_namespace.split('/')[1]}
|
||||
projectId={selectedRepo.id}
|
||||
token={connection?.token || ''}
|
||||
gitlabUrl={connection?.gitlabUrl}
|
||||
defaultBranch={selectedRepo.default_branch}
|
||||
onBranchSelect={handleBranchSelect}
|
||||
onClose={handleCloseBranchSelector}
|
||||
isOpen={isBranchSelectorOpen}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { GitLabProjectInfo } from '~/types/GitLab';
|
||||
|
||||
interface RepositoryCardProps {
|
||||
repo: GitLabProjectInfo;
|
||||
onClone?: (repo: GitLabProjectInfo) => void;
|
||||
}
|
||||
|
||||
export function RepositoryCard({ repo, onClone }: RepositoryCardProps) {
|
||||
return (
|
||||
<a
|
||||
key={repo.name}
|
||||
href={repo.http_url_to_repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info" />
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-bolt-elements-icon-warning" />
|
||||
{repo.star_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-bolt-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary line-clamp-2">{repo.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{onClone && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClone(repo);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
title="Clone repository"
|
||||
>
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
Clone
|
||||
</button>
|
||||
)}
|
||||
<span className="flex items-center gap-1 group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { RepositoryCard } from './RepositoryCard';
|
||||
import type { GitLabProjectInfo } from '~/types/GitLab';
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: GitLabProjectInfo[];
|
||||
onClone?: (repo: GitLabProjectInfo) => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
const MAX_REPOS_PER_PAGE = 20;
|
||||
|
||||
export function RepositoryList({ repositories, onClone, onRefresh, isRefreshing }: RepositoryListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!searchQuery) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
|
||||
const filtered = repositories.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.path_with_namespace.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(repo.description && repo.description.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
);
|
||||
|
||||
setIsSearching(false);
|
||||
|
||||
return filtered;
|
||||
}, [repositories, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRepositories.length / MAX_REPOS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * MAX_REPOS_PER_PAGE;
|
||||
const endIndex = startIndex + MAX_REPOS_PER_PAGE;
|
||||
const currentRepositories = filteredRepositories.slice(startIndex, endIndex);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1); // Reset to first page when searching
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Repositories ({filteredRepositories.length})
|
||||
</h4>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="i-ph:spinner animate-spin w-4 h-4" />
|
||||
) : (
|
||||
<div className="i-ph:arrows-clockwise w-4 h-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
||||
{isSearching ? (
|
||||
<div className="i-ph:spinner animate-spin w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
) : (
|
||||
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Grid */}
|
||||
<div className="space-y-4">
|
||||
{filteredRepositories.length === 0 ? (
|
||||
<div className="text-center py-8 text-bolt-elements-textSecondary">
|
||||
{searchQuery ? 'No repositories found matching your search.' : 'No repositories available.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentRepositories.map((repo) => (
|
||||
<RepositoryCard key={repo.id} repo={repo} onClone={onClone} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-bolt-elements-borderColor">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">
|
||||
Showing {Math.min(startIndex + 1, filteredRepositories.length)} to{' '}
|
||||
{Math.min(endIndex, filteredRepositories.length)} of {filteredRepositories.length} repositories
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<div className="i-ph:caret-left w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary px-3">
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Next
|
||||
<div className="i-ph:caret-right w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import type { GitLabStats } from '~/types/GitLab';
|
||||
|
||||
interface StatsDisplayProps {
|
||||
stats: GitLabStats;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function StatsDisplay({ stats, onRefresh, isRefreshing }: StatsDisplayProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Repository Stats */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Repository Stats</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public Repos',
|
||||
value: stats.publicProjects,
|
||||
},
|
||||
{
|
||||
label: 'Private Repos',
|
||||
value: stats.privateProjects,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contribution Stats */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Contribution Stats</h5>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Stars',
|
||||
value: stats.stars || 0,
|
||||
icon: 'i-ph:star',
|
||||
iconColor: 'text-bolt-elements-icon-warning',
|
||||
},
|
||||
{
|
||||
label: 'Forks',
|
||||
value: stats.forks || 0,
|
||||
icon: 'i-ph:git-fork',
|
||||
iconColor: 'text-bolt-elements-icon-info',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: stats.followers || 0,
|
||||
icon: 'i-ph:users',
|
||||
iconColor: 'text-bolt-elements-icon-success',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-bolt-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-bolt-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} w-4 h-4 ${stat.iconColor}`} />
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-bolt-elements-borderColor">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-bolt-elements-textSecondary">
|
||||
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
{onRefresh && (
|
||||
<Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm" className="text-xs">
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as GitLabConnection } from './GitLabConnection';
|
||||
export { RepositoryCard } from './RepositoryCard';
|
||||
export { RepositoryList } from './RepositoryList';
|
||||
export { StatsDisplay } from './StatsDisplay';
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { MCPServer } from '~/lib/services/mcpService';
|
||||
import McpStatusBadge from '~/components/@settings/tabs/mcp/McpStatusBadge';
|
||||
import McpServerListItem from '~/components/@settings/tabs/mcp/McpServerListItem';
|
||||
|
||||
type McpServerListProps = {
|
||||
serverEntries: [string, MCPServer][];
|
||||
expandedServer: string | null;
|
||||
checkingServers: boolean;
|
||||
onlyShowAvailableServers?: boolean;
|
||||
toggleServerExpanded: (serverName: string) => void;
|
||||
};
|
||||
|
||||
export default function McpServerList({
|
||||
serverEntries,
|
||||
expandedServer,
|
||||
checkingServers,
|
||||
onlyShowAvailableServers = false,
|
||||
toggleServerExpanded,
|
||||
}: McpServerListProps) {
|
||||
if (serverEntries.length === 0) {
|
||||
return <p className="text-sm text-bolt-elements-textSecondary">No MCP servers configured</p>;
|
||||
}
|
||||
|
||||
const filteredEntries = onlyShowAvailableServers
|
||||
? serverEntries.filter(([, s]) => s.status === 'available')
|
||||
: serverEntries;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredEntries.map(([serverName, mcpServer]) => {
|
||||
const isAvailable = mcpServer.status === 'available';
|
||||
const isExpanded = expandedServer === serverName;
|
||||
const serverTools = isAvailable ? Object.entries(mcpServer.tools) : [];
|
||||
|
||||
return (
|
||||
<div key={serverName} className="flex flex-col p-2 rounded-md bg-bolt-elements-background-depth-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
onClick={() => toggleServerExpanded(serverName)}
|
||||
className="flex items-center gap-1.5 text-bolt-elements-textPrimary"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div
|
||||
className={`i-ph:${isExpanded ? 'caret-down' : 'caret-right'} w-3 h-3 transition-transform duration-150`}
|
||||
/>
|
||||
<span className="font-medium truncate text-left">{serverName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 truncate">
|
||||
{mcpServer.config.type === 'sse' || mcpServer.config.type === 'streamable-http' ? (
|
||||
<span className="text-xs text-bolt-elements-textSecondary truncate">{mcpServer.config.url}</span>
|
||||
) : (
|
||||
<span className="text-xs text-bolt-elements-textSecondary truncate">
|
||||
{mcpServer.config.command} {mcpServer.config.args?.join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
{checkingServers ? (
|
||||
<McpStatusBadge status="checking" />
|
||||
) : (
|
||||
<McpStatusBadge status={isAvailable ? 'available' : 'unavailable'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{!isAvailable && mcpServer.error && (
|
||||
<div className="mt-1.5 ml-6 text-xs text-red-600 dark:text-red-400">Error: {mcpServer.error}</div>
|
||||
)}
|
||||
|
||||
{/* Tool list */}
|
||||
{isExpanded && isAvailable && (
|
||||
<div className="mt-2">
|
||||
<div className="text-bolt-elements-textSecondary text-xs font-medium ml-1 mb-1.5">Available Tools:</div>
|
||||
{serverTools.length === 0 ? (
|
||||
<div className="ml-4 text-xs text-bolt-elements-textSecondary">No tools available</div>
|
||||
) : (
|
||||
<div className="mt-1 space-y-2">
|
||||
{serverTools.map(([toolName, toolSchema]) => (
|
||||
<McpServerListItem
|
||||
key={`${serverName}-${toolName}`}
|
||||
toolName={toolName}
|
||||
toolSchema={toolSchema}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { Tool } from 'ai';
|
||||
|
||||
type ParameterProperty = {
|
||||
type?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ToolParameters = {
|
||||
jsonSchema: {
|
||||
properties?: Record<string, ParameterProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type McpToolProps = {
|
||||
toolName: string;
|
||||
toolSchema: Tool;
|
||||
};
|
||||
|
||||
export default function McpServerListItem({ toolName, toolSchema }: McpToolProps) {
|
||||
if (!toolSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parameters = (toolSchema.parameters as ToolParameters)?.jsonSchema.properties || {};
|
||||
const requiredParams = (toolSchema.parameters as ToolParameters)?.jsonSchema.required || [];
|
||||
|
||||
return (
|
||||
<div className="mt-2 ml-4 p-3 rounded-md bg-bolt-elements-background-depth-2 text-xs">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-bolt-elements-textPrimary font-semibold truncate" title={toolName}>
|
||||
{toolName}
|
||||
</h3>
|
||||
|
||||
<p className="text-bolt-elements-textSecondary">{toolSchema.description || 'No description available'}</p>
|
||||
|
||||
{Object.keys(parameters).length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<h4 className="text-bolt-elements-textSecondary font-semibold mb-1.5">Parameters:</h4>
|
||||
<ul className="ml-1 space-y-2">
|
||||
{Object.entries(parameters).map(([paramName, paramDetails]) => (
|
||||
<li key={paramName} className="break-words">
|
||||
<div className="flex items-start">
|
||||
<span className="font-medium text-bolt-elements-textPrimary">
|
||||
{paramName}
|
||||
{requiredParams.includes(paramName) && (
|
||||
<span className="text-red-600 dark:text-red-400 ml-1">*</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="mx-2 text-bolt-elements-textSecondary">•</span>
|
||||
|
||||
<div className="flex-1">
|
||||
{paramDetails.type && (
|
||||
<span className="text-bolt-elements-textSecondary italic">{paramDetails.type}</span>
|
||||
)}
|
||||
{paramDetails.description && (
|
||||
<div className="mt-0.5 text-bolt-elements-textSecondary">{paramDetails.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function McpStatusBadge({ status }: { status: 'checking' | 'available' | 'unavailable' }) {
|
||||
const { styles, label, icon, ariaLabel } = useMemo(() => {
|
||||
const base = 'px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 transition-colors';
|
||||
|
||||
const config = {
|
||||
checking: {
|
||||
styles: `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/80 dark:text-blue-200`,
|
||||
label: 'Checking...',
|
||||
ariaLabel: 'Checking server status',
|
||||
icon: <span className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-current animate-spin" aria-hidden="true" />,
|
||||
},
|
||||
available: {
|
||||
styles: `${base} bg-green-100 text-green-800 dark:bg-green-900/80 dark:text-green-200`,
|
||||
label: 'Available',
|
||||
ariaLabel: 'Server available',
|
||||
icon: <span className="i-ph:check-circle w-3 h-3 text-current" aria-hidden="true" />,
|
||||
},
|
||||
unavailable: {
|
||||
styles: `${base} bg-red-100 text-red-800 dark:bg-red-900/80 dark:text-red-200`,
|
||||
label: 'Unavailable',
|
||||
ariaLabel: 'Server unavailable',
|
||||
icon: <span className="i-ph:warning-circle w-3 h-3 text-current" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
return config[status];
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<span className={styles} role="status" aria-live="polite" aria-label={ariaLabel}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { MCPConfig } from '~/lib/services/mcpService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMCPStore } from '~/lib/stores/mcp';
|
||||
import McpServerList from '~/components/@settings/tabs/mcp/McpServerList';
|
||||
|
||||
const EXAMPLE_MCP_CONFIG: MCPConfig = {
|
||||
mcpServers: {
|
||||
everything: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-everything'],
|
||||
},
|
||||
deepwiki: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.deepwiki.com/mcp',
|
||||
},
|
||||
'local-sse': {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:8000/sse',
|
||||
headers: {
|
||||
Authorization: 'Bearer mytoken123',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function McpTab() {
|
||||
const settings = useMCPStore((state) => state.settings);
|
||||
const isInitialized = useMCPStore((state) => state.isInitialized);
|
||||
const serverTools = useMCPStore((state) => state.serverTools);
|
||||
const initialize = useMCPStore((state) => state.initialize);
|
||||
const updateSettings = useMCPStore((state) => state.updateSettings);
|
||||
const checkServersAvailabilities = useMCPStore((state) => state.checkServersAvailabilities);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [mcpConfigText, setMCPConfigText] = useState('');
|
||||
const [maxLLMSteps, setMaxLLMSteps] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCheckingServers, setIsCheckingServers] = useState(false);
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
initialize().catch((err) => {
|
||||
setError(`Failed to initialize MCP settings: ${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.error('Failed to load MCP configuration');
|
||||
});
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
setMCPConfigText(JSON.stringify(settings.mcpConfig, null, 2));
|
||||
setMaxLLMSteps(settings.maxLLMSteps);
|
||||
setError(null);
|
||||
}, [settings]);
|
||||
|
||||
const parsedConfig = useMemo(() => {
|
||||
try {
|
||||
setError(null);
|
||||
return JSON.parse(mcpConfigText) as MCPConfig;
|
||||
} catch (e) {
|
||||
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return null;
|
||||
}
|
||||
}, [mcpConfigText]);
|
||||
|
||||
const handleMaxLLMCallChange = (value: string) => {
|
||||
setMaxLLMSteps(parseInt(value, 10));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!parsedConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await updateSettings({
|
||||
mcpConfig: parsedConfig,
|
||||
maxLLMSteps,
|
||||
});
|
||||
toast.success('MCP configuration saved');
|
||||
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save configuration');
|
||||
toast.error('Failed to save MCP configuration');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadExample = () => {
|
||||
setMCPConfigText(JSON.stringify(EXAMPLE_MCP_CONFIG, null, 2));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const checkServerAvailability = async () => {
|
||||
if (serverEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingServers(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await checkServersAvailabilities();
|
||||
} catch (e) {
|
||||
setError(`Failed to check server availability: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setIsCheckingServers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServerExpanded = (serverName: string) => {
|
||||
setExpandedServer(expandedServer === serverName ? null : serverName);
|
||||
};
|
||||
|
||||
const serverEntries = useMemo(() => Object.entries(serverTools), [serverTools]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<section aria-labelledby="server-status-heading">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-medium text-bolt-elements-textPrimary">MCP Servers Configured</h2>{' '}
|
||||
<button
|
||||
onClick={checkServerAvailability}
|
||||
disabled={isCheckingServers || !parsedConfig || serverEntries.length === 0}
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg text-sm',
|
||||
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'transition-all duration-200',
|
||||
'flex items-center gap-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isCheckingServers ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
|
||||
) : (
|
||||
<div className="i-ph:arrow-counter-clockwise w-3 h-3" />
|
||||
)}
|
||||
Check availability
|
||||
</button>
|
||||
</div>
|
||||
<McpServerList
|
||||
checkingServers={isCheckingServers}
|
||||
expandedServer={expandedServer}
|
||||
serverEntries={serverEntries}
|
||||
toggleServerExpanded={toggleServerExpanded}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="config-section-heading">
|
||||
<h2 className="text-base font-medium text-bolt-elements-textPrimary mb-3">Configuration</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="mcp-config" className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
Configuration JSON
|
||||
</label>
|
||||
<textarea
|
||||
id="mcp-config"
|
||||
value={mcpConfigText}
|
||||
onChange={(e) => setMCPConfigText(e.target.value)}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm font-mono h-72',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border',
|
||||
error ? 'border-bolt-elements-icon-error' : 'border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>{error && <p className="mt-2 mb-2 text-sm text-bolt-elements-icon-error">{error}</p>}</div>
|
||||
<div>
|
||||
<label htmlFor="max-llm-steps" className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
Maximum number of sequential LLM calls (steps)
|
||||
</label>
|
||||
<input
|
||||
id="max-llm-steps"
|
||||
type="number"
|
||||
placeholder="Maximum number of sequential LLM calls"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxLLMSteps}
|
||||
onChange={(e) => handleMaxLLMCallChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-bolt-elements-textPrimary text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
The MCP configuration format is identical to the one used in Claude Desktop.
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-link hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View example servers
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleLoadExample}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-bolt-elements-borderColor
|
||||
bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary
|
||||
hover:bg-bolt-elements-background-depth-3"
|
||||
>
|
||||
Load Example
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !parsedConfig}
|
||||
aria-disabled={isSaving || !parsedConfig}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent',
|
||||
'hover:bg-bolt-elements-item-backgroundActive',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:floppy-disk w-4 h-4" />
|
||||
{isSaving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
export { default as NetlifyConnection } from './NetlifyConnection';
|
||||
@@ -8,7 +8,7 @@ import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { toast } from 'react-toastify';
|
||||
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
||||
import { SiAmazon, SiGoogle, SiGithub, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
||||
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
||||
import { BsRobot, BsCloud } from 'react-icons/bs';
|
||||
import { TbBrain, TbCloudComputing } from 'react-icons/tb';
|
||||
import { BiCodeBlock, BiChip } from 'react-icons/bi';
|
||||
@@ -21,7 +21,6 @@ type ProviderName =
|
||||
| 'Anthropic'
|
||||
| 'Cohere'
|
||||
| 'Deepseek'
|
||||
| 'Github'
|
||||
| 'Google'
|
||||
| 'Groq'
|
||||
| 'HuggingFace'
|
||||
@@ -39,7 +38,6 @@ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
||||
Anthropic: FaBrain,
|
||||
Cohere: BiChip,
|
||||
Deepseek: BiCodeBlock,
|
||||
Github: SiGithub,
|
||||
Google: SiGoogle,
|
||||
Groq: BsCloud,
|
||||
HuggingFace: SiHuggingface,
|
||||
@@ -55,7 +53,6 @@ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
||||
// Update PROVIDER_DESCRIPTIONS to use the same type
|
||||
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
|
||||
Anthropic: 'Access Claude and other Anthropic models',
|
||||
Github: 'Use OpenAI models hosted through GitHub infrastructure',
|
||||
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Local Providers Error Boundary caught an error:', error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('p-6 rounded-lg border border-red-500/20', 'bg-red-500/5 text-center')}>
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-red-500 mb-2">Something went wrong</h3>
|
||||
<p className="text-sm text-red-400 mb-4">There was an error loading the local providers section.</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium',
|
||||
'bg-red-500/10 text-red-500',
|
||||
'hover:bg-red-500/20',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="cursor-pointer text-sm text-red-400 hover:text-red-300">Error Details</summary>
|
||||
<pre className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-300 overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface HealthStatusBadgeProps {
|
||||
status: 'healthy' | 'unhealthy' | 'checking' | 'unknown';
|
||||
responseTime?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function HealthStatusBadge({ status, responseTime, className }: HealthStatusBadgeProps) {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return {
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10 border-green-500/20',
|
||||
Icon: CheckCircle,
|
||||
label: 'Healthy',
|
||||
};
|
||||
case 'unhealthy':
|
||||
return {
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10 border-red-500/20',
|
||||
Icon: XCircle,
|
||||
label: 'Unhealthy',
|
||||
};
|
||||
case 'checking':
|
||||
return {
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/20',
|
||||
Icon: Loader2,
|
||||
label: 'Checking',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'text-bolt-elements-textTertiary',
|
||||
bgColor: 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor',
|
||||
Icon: AlertCircle,
|
||||
label: 'Unknown',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const Icon = config.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
config.bgColor,
|
||||
config.color,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className={classNames('w-3 h-3', { 'animate-spin': status === 'checking' })} />
|
||||
<span>{config.label}</span>
|
||||
{responseTime !== undefined && status === 'healthy' && <span className="opacity-75">({responseTime}ms)</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthStatusBadge;
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
className?: string;
|
||||
lines?: number;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({ className, lines = 1, height = 'h-4' }: LoadingSkeletonProps) {
|
||||
return (
|
||||
<div className={classNames('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classNames('bg-bolt-elements-background-depth-3 rounded', height, 'animate-pulse')}
|
||||
style={{ animationDelay: `${i * 0.1}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelCardSkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelCardSkeleton({ className }: ModelCardSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'border rounded-lg p-4',
|
||||
'bg-bolt-elements-background-depth-2',
|
||||
'border-bolt-elements-borderColor',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-3 h-3 rounded-full bg-bolt-elements-textTertiary animate-pulse" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<LoadingSkeleton height="h-5" lines={1} className="w-3/4" />
|
||||
<LoadingSkeleton height="h-3" lines={1} className="w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-4 h-4 bg-bolt-elements-textTertiary rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderCardSkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderCardSkeleton({ className }: ProviderCardSkeletonProps) {
|
||||
return (
|
||||
<div className={classNames('bg-bolt-elements-background-depth-2 rounded-xl p-5', className)}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 animate-pulse" />
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton height="h-5" lines={1} className="w-1/3" />
|
||||
<LoadingSkeleton height="h-4" lines={1} className="w-2/3" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton height="h-3" lines={1} className="w-1/4" />
|
||||
<LoadingSkeleton height="h-8" lines={1} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-6 bg-bolt-elements-background-depth-3 rounded-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelManagerSkeletonProps {
|
||||
className?: string;
|
||||
cardCount?: number;
|
||||
}
|
||||
|
||||
export function ModelManagerSkeleton({ className, cardCount = 3 }: ModelManagerSkeletonProps) {
|
||||
return (
|
||||
<div className={classNames('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton height="h-6" lines={1} className="w-48" />
|
||||
<LoadingSkeleton height="h-4" lines={1} className="w-64" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
|
||||
<div className="w-16 h-8 bg-bolt-elements-background-depth-3 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Cards */}
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: cardCount }).map((_, i) => (
|
||||
<ModelCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '~/components/ui/Card';
|
||||
import { Progress } from '~/components/ui/Progress';
|
||||
import { RotateCw, Trash2, Code, Database, Package, Loader2 } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { OllamaModel } from './types';
|
||||
|
||||
// Model Card Component
|
||||
interface ModelCardProps {
|
||||
model: OllamaModel;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ModelCard({ model, onUpdate, onDelete }: ModelCardProps) {
|
||||
return (
|
||||
<Card className="bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 transition-all duration-200 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/20">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary font-mono">{model.name}</h4>
|
||||
{model.status && model.status !== 'idle' && (
|
||||
<span
|
||||
className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', {
|
||||
'bg-yellow-500/10 text-yellow-500': model.status === 'updating',
|
||||
'bg-green-500/10 text-green-500': model.status === 'updated',
|
||||
'bg-red-500/10 text-red-500': model.status === 'error',
|
||||
})}
|
||||
>
|
||||
{model.status === 'updating' && 'Updating'}
|
||||
{model.status === 'updated' && 'Updated'}
|
||||
{model.status === 'error' && 'Error'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-bolt-elements-textSecondary">
|
||||
<div className="flex items-center gap-1">
|
||||
<Code className="w-3 h-3" />
|
||||
<span>{model.digest.substring(0, 8)}</span>
|
||||
</div>
|
||||
{model.details && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" />
|
||||
<span>{model.details.parameter_size}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
<span>{model.details.quantization_level}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
disabled={model.status === 'updating'}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
|
||||
'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20 hover:shadow-sm',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-purple-500/10',
|
||||
)}
|
||||
>
|
||||
{model.status === 'updating' ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Updating
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCw className="w-3 h-3" />
|
||||
Update
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={model.status === 'updating'}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-3 py-2 text-xs rounded-lg transition-all duration-200',
|
||||
'bg-red-500/10 text-red-500 hover:bg-red-500/20 hover:shadow-sm',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-500/10',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{model.progress && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex justify-between text-xs text-bolt-elements-textSecondary">
|
||||
<span>{model.progress.status}</span>
|
||||
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
||||
</div>
|
||||
<Progress value={Math.round((model.progress.current / model.progress.total) * 100)} className="h-1" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCard;
|
||||
@@ -0,0 +1,603 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Progress } from '~/components/ui/Progress';
|
||||
import { useToast } from '~/components/ui/use-toast';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
|
||||
interface OllamaModelInstallerProps {
|
||||
onModelInstalled: () => void;
|
||||
}
|
||||
|
||||
interface InstallProgress {
|
||||
status: string;
|
||||
progress: number;
|
||||
downloadedSize?: string;
|
||||
totalSize?: string;
|
||||
speed?: string;
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
desc: string;
|
||||
size: string;
|
||||
tags: string[];
|
||||
installedVersion?: string;
|
||||
latestVersion?: string;
|
||||
needsUpdate?: boolean;
|
||||
status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
|
||||
details?: {
|
||||
family: string;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
}
|
||||
|
||||
const POPULAR_MODELS: ModelInfo[] = [
|
||||
{
|
||||
name: 'deepseek-coder:6.7b',
|
||||
desc: "DeepSeek's code generation model",
|
||||
size: '4.1GB',
|
||||
tags: ['coding', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'llama2:7b',
|
||||
desc: "Meta's Llama 2 (7B parameters)",
|
||||
size: '3.8GB',
|
||||
tags: ['general', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'mistral:7b',
|
||||
desc: "Mistral's 7B model",
|
||||
size: '4.1GB',
|
||||
tags: ['general', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'gemma:7b',
|
||||
desc: "Google's Gemma model",
|
||||
size: '4.0GB',
|
||||
tags: ['general', 'new'],
|
||||
},
|
||||
{
|
||||
name: 'codellama:7b',
|
||||
desc: "Meta's Code Llama model",
|
||||
size: '4.1GB',
|
||||
tags: ['coding', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'neural-chat:7b',
|
||||
desc: "Intel's Neural Chat model",
|
||||
size: '4.1GB',
|
||||
tags: ['chat', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'phi:latest',
|
||||
desc: "Microsoft's Phi-2 model",
|
||||
size: '2.7GB',
|
||||
tags: ['small', 'fast'],
|
||||
},
|
||||
{
|
||||
name: 'qwen:7b',
|
||||
desc: "Alibaba's Qwen model",
|
||||
size: '4.1GB',
|
||||
tags: ['general'],
|
||||
},
|
||||
{
|
||||
name: 'solar:10.7b',
|
||||
desc: "Upstage's Solar model",
|
||||
size: '6.1GB',
|
||||
tags: ['large', 'powerful'],
|
||||
},
|
||||
{
|
||||
name: 'openchat:7b',
|
||||
desc: 'Open-source chat model',
|
||||
size: '4.1GB',
|
||||
tags: ['chat', 'popular'],
|
||||
},
|
||||
{
|
||||
name: 'dolphin-phi:2.7b',
|
||||
desc: 'Lightweight chat model',
|
||||
size: '1.6GB',
|
||||
tags: ['small', 'fast'],
|
||||
},
|
||||
{
|
||||
name: 'stable-code:3b',
|
||||
desc: 'Lightweight coding model',
|
||||
size: '1.8GB',
|
||||
tags: ['coding', 'small'],
|
||||
},
|
||||
];
|
||||
|
||||
function formatBytes(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]}`;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number): string {
|
||||
return `${formatBytes(bytesPerSecond)}/s`;
|
||||
}
|
||||
|
||||
// Add Ollama Icon SVG component
|
||||
function OllamaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
|
||||
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
|
||||
const [modelString, setModelString] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
|
||||
const { toast } = useToast();
|
||||
const { providers } = useSettings();
|
||||
|
||||
// Get base URL from provider settings
|
||||
const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
||||
|
||||
// Function to check installed models and their versions
|
||||
const checkInstalledModels = async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch installed models');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
|
||||
const installedModels = data.models || [];
|
||||
|
||||
// Update models with installed versions
|
||||
setModels((prevModels) =>
|
||||
prevModels.map((model) => {
|
||||
const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
|
||||
|
||||
if (installed) {
|
||||
return {
|
||||
...model,
|
||||
installedVersion: installed.digest.substring(0, 8),
|
||||
needsUpdate: installed.digest !== installed.latest,
|
||||
latestVersion: installed.latest?.substring(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
return model;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error checking installed models:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check installed models on mount and after installation
|
||||
useEffect(() => {
|
||||
checkInstalledModels();
|
||||
}, [baseUrl]);
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setIsChecking(true);
|
||||
|
||||
try {
|
||||
await checkInstalledModels();
|
||||
toast('Model versions checked');
|
||||
} catch (err) {
|
||||
console.error('Failed to check model versions:', err);
|
||||
toast('Failed to check model versions');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModels = models.filter((model) => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
model.desc.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
|
||||
|
||||
return matchesSearch && matchesTags;
|
||||
});
|
||||
|
||||
const handleInstallModel = async (modelToInstall: string) => {
|
||||
if (!modelToInstall) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
setInstallProgress({
|
||||
status: 'Starting download...',
|
||||
progress: 0,
|
||||
downloadedSize: '0 B',
|
||||
totalSize: 'Calculating...',
|
||||
speed: '0 B/s',
|
||||
});
|
||||
setModelString('');
|
||||
setSearchQuery('');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: modelToInstall }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
let lastTime = Date.now();
|
||||
let lastBytes = 0;
|
||||
|
||||
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 ('status' in data) {
|
||||
const currentTime = Date.now();
|
||||
const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
|
||||
const bytesDiff = (data.completed || 0) - lastBytes;
|
||||
const speed = bytesDiff / timeDiff;
|
||||
|
||||
setInstallProgress({
|
||||
status: data.status,
|
||||
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
||||
downloadedSize: formatBytes(data.completed || 0),
|
||||
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
||||
speed: formatSpeed(speed),
|
||||
});
|
||||
|
||||
lastTime = currentTime;
|
||||
lastBytes = data.completed || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing progress:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
|
||||
|
||||
// Ensure we call onModelInstalled after successful installation
|
||||
setTimeout(() => {
|
||||
onModelInstalled();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error(`Error installing ${modelToInstall}:`, errorMessage);
|
||||
toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
setInstallProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateModel = async (modelToUpdate: string) => {
|
||||
try {
|
||||
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: modelToUpdate }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
}
|
||||
|
||||
let lastTime = Date.now();
|
||||
let lastBytes = 0;
|
||||
|
||||
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 ('status' in data) {
|
||||
const currentTime = Date.now();
|
||||
const timeDiff = (currentTime - lastTime) / 1000;
|
||||
const bytesDiff = (data.completed || 0) - lastBytes;
|
||||
const speed = bytesDiff / timeDiff;
|
||||
|
||||
setInstallProgress({
|
||||
status: data.status,
|
||||
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
||||
downloadedSize: formatBytes(data.completed || 0),
|
||||
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
||||
speed: formatSpeed(speed),
|
||||
});
|
||||
|
||||
lastTime = currentTime;
|
||||
lastBytes = data.completed || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing progress:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast('Successfully updated ' + modelToUpdate);
|
||||
|
||||
// Refresh model list after update
|
||||
await checkInstalledModels();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error(`Error updating ${modelToUpdate}:`, errorMessage);
|
||||
toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
|
||||
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
|
||||
} finally {
|
||||
setInstallProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<OllamaIcon className="w-8 h-8 text-purple-500" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={handleCheckUpdates}
|
||||
disabled={isChecking}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg',
|
||||
'bg-purple-500/10 text-purple-500',
|
||||
'hover:bg-purple-500/20',
|
||||
'transition-all duration-200',
|
||||
'flex items-center gap-2',
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isChecking ? (
|
||||
<div className="i-ph:spinner-gap-bold animate-spin" />
|
||||
) : (
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
)}
|
||||
Check Updates
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
className={classNames(
|
||||
'w-full px-4 py-3 rounded-xl',
|
||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
||||
'transition-all duration-200',
|
||||
)}
|
||||
placeholder="Search models or enter custom model name..."
|
||||
value={searchQuery || modelString}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
setModelString(value);
|
||||
}}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
<p className="text-sm text-bolt-elements-textSecondary px-1">
|
||||
Browse models at{' '}
|
||||
<a
|
||||
href="https://ollama.com/library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
|
||||
>
|
||||
ollama.com/library
|
||||
<div className="i-ph:arrow-square-out text-sm" />
|
||||
</a>{' '}
|
||||
and copy model names to install
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => handleInstallModel(modelString)}
|
||||
disabled={!modelString || isInstalling}
|
||||
className={classNames(
|
||||
'rounded-lg px-4 py-2',
|
||||
'bg-purple-500 text-white text-sm',
|
||||
'hover:bg-purple-600',
|
||||
'transition-all duration-200',
|
||||
'flex items-center gap-2',
|
||||
{ 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
||||
<span>Installing...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<OllamaIcon className="w-4 h-4" />
|
||||
<span>Install Model</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
||||
}}
|
||||
className={classNames(
|
||||
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{filteredModels.map((model) => (
|
||||
<motion.div
|
||||
key={model.name}
|
||||
className={classNames(
|
||||
'flex items-start gap-2 p-3 rounded-lg',
|
||||
'bg-bolt-elements-background-depth-3',
|
||||
'hover:bg-bolt-elements-background-depth-4',
|
||||
'transition-all duration-200',
|
||||
'relative group',
|
||||
)}
|
||||
>
|
||||
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
|
||||
{model.installedVersion && (
|
||||
<div className="mt-0.5 flex flex-col items-end gap-0.5">
|
||||
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
|
||||
{model.needsUpdate && model.latestVersion && (
|
||||
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{model.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{model.installedVersion ? (
|
||||
model.needsUpdate ? (
|
||||
<motion.button
|
||||
onClick={() => handleUpdateModel(model.name)}
|
||||
className={classNames(
|
||||
'px-2 py-0.5 rounded-lg text-xs',
|
||||
'bg-purple-500 text-white',
|
||||
'hover:bg-purple-600',
|
||||
'transition-all duration-200',
|
||||
'flex items-center gap-1',
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise text-xs" />
|
||||
Update
|
||||
</motion.button>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
|
||||
)
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => handleInstallModel(model.name)}
|
||||
className={classNames(
|
||||
'px-2 py-0.5 rounded-lg text-xs',
|
||||
'bg-purple-500 text-white',
|
||||
'hover:bg-purple-600',
|
||||
'transition-all duration-200',
|
||||
'flex items-center gap-1',
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:download text-xs" />
|
||||
Install
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{installProgress && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-bolt-elements-textTertiary">
|
||||
{installProgress.downloadedSize} / {installProgress.totalSize}
|
||||
</span>
|
||||
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
|
||||
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={installProgress.progress} className="h-1" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { Card, CardContent } from '~/components/ui/Card';
|
||||
import { Link, Server, Monitor, Globe } from 'lucide-react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import type { IProviderConfig } from '~/types/model';
|
||||
import { PROVIDER_DESCRIPTIONS } from './types';
|
||||
|
||||
// Provider Card Component
|
||||
interface ProviderCardProps {
|
||||
provider: IProviderConfig;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdateBaseUrl: (url: string) => void;
|
||||
isEditing: boolean;
|
||||
onStartEditing: () => void;
|
||||
onStopEditing: () => void;
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
onToggle,
|
||||
onUpdateBaseUrl,
|
||||
isEditing,
|
||||
onStartEditing,
|
||||
onStopEditing,
|
||||
}: ProviderCardProps) {
|
||||
const getIcon = (providerName: string) => {
|
||||
switch (providerName) {
|
||||
case 'Ollama':
|
||||
return Server;
|
||||
case 'LMStudio':
|
||||
return Monitor;
|
||||
case 'OpenAILike':
|
||||
return Globe;
|
||||
default:
|
||||
return Server;
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = getIcon(provider.name);
|
||||
|
||||
return (
|
||||
<Card className="bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-all duration-300 shadow-sm hover:shadow-md border border-bolt-elements-borderColor hover:border-purple-500/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300',
|
||||
provider.settings.enabled
|
||||
? 'bg-gradient-to-br from-purple-500/20 to-purple-600/20 ring-1 ring-purple-500/30'
|
||||
: 'bg-bolt-elements-background-depth-3',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={classNames(
|
||||
'w-6 h-6 transition-all duration-300',
|
||||
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textTertiary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-500/10 text-green-500 font-medium">Local</span>
|
||||
</div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS]}
|
||||
</p>
|
||||
|
||||
{provider.settings.enabled && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-bolt-elements-textPrimary">API Endpoint</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={provider.settings.baseUrl}
|
||||
placeholder={`Enter ${provider.name} base URL`}
|
||||
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-4 border border-purple-500/30 text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 shadow-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onUpdateBaseUrl(e.currentTarget.value);
|
||||
onStopEditing();
|
||||
} else if (e.key === 'Escape') {
|
||||
onStopEditing();
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onUpdateBaseUrl(e.target.value);
|
||||
onStopEditing();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStartEditing}
|
||||
className="w-full px-4 py-3 rounded-lg text-sm bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4 hover:shadow-sm transition-all duration-200 text-left group"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-bolt-elements-textSecondary group-hover:text-bolt-elements-textPrimary">
|
||||
<Link className="w-4 h-4 group-hover:text-purple-500 transition-colors" />
|
||||
<span className="font-mono">{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={provider.settings.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`Toggle ${provider.name} provider`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -1,671 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader } from '~/components/ui/Card';
|
||||
import {
|
||||
Cpu,
|
||||
Server,
|
||||
Settings,
|
||||
ExternalLink,
|
||||
Package,
|
||||
Code,
|
||||
Database,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Cable,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Shield,
|
||||
Globe,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Wifi,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Setup Guide Component
|
||||
function SetupGuide({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Back Button */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
|
||||
aria-label="Back to Dashboard"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Local Provider Setup Guide</h2>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Complete setup instructions for running AI models locally
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hardware Requirements Overview */}
|
||||
<Card className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">System Requirements</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Recommended hardware for optimal performance</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-green-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">CPU</span>
|
||||
</div>
|
||||
<p className="text-bolt-elements-textSecondary">8+ cores, modern architecture</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">RAM</span>
|
||||
</div>
|
||||
<p className="text-bolt-elements-textSecondary">16GB minimum, 32GB+ recommended</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-purple-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">GPU</span>
|
||||
</div>
|
||||
<p className="text-bolt-elements-textSecondary">NVIDIA RTX 30xx+ or AMD RX 6000+</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ollama Setup Section */}
|
||||
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/20 flex items-center justify-center ring-1 ring-purple-500/30">
|
||||
<Server className="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Ollama Setup</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Most popular choice for running open-source models locally with desktop app
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-purple-500/10 text-purple-500 text-xs font-medium rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Installation Options */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
1. Choose Installation Method
|
||||
</h4>
|
||||
|
||||
{/* Desktop App - New and Recommended */}
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Monitor className="w-5 h-5 text-green-500" />
|
||||
<h5 className="font-medium text-green-500">🆕 Desktop App (Recommended)</h5>
|
||||
</div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||
New user-friendly desktop application with built-in model management and web interface.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||
<strong className="text-bolt-elements-textPrimary">macOS</strong>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
|
||||
_asChild
|
||||
>
|
||||
<a
|
||||
href="https://ollama.com/download/mac"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||
<span className="flex-1 text-center font-medium">Download Desktop App</span>
|
||||
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||
<strong className="text-bolt-elements-textPrimary">Windows</strong>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-gradient-to-r from-purple-500/10 to-purple-600/10 hover:from-purple-500/20 hover:to-purple-600/20 border-purple-500/30 hover:border-purple-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-purple-500/20 font-medium"
|
||||
_asChild
|
||||
>
|
||||
<a
|
||||
href="https://ollama.com/download/windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||
<span className="flex-1 text-center font-medium">Download Desktop App</span>
|
||||
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Globe className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-medium text-blue-500 text-sm">Built-in Web Interface</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
Desktop app includes a web interface at{' '}
|
||||
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">http://localhost:11434</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLI Installation */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Terminal className="w-5 h-5 text-bolt-elements-textPrimary" />
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary">Command Line (Advanced)</h5>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||
<strong className="text-bolt-elements-textPrimary">Windows</strong>
|
||||
</div>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||
winget install Ollama.Ollama
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Monitor className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||
<strong className="text-bolt-elements-textPrimary">macOS</strong>
|
||||
</div>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||
brew install ollama
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4 text-bolt-elements-textPrimary" />
|
||||
<strong className="text-bolt-elements-textPrimary">Linux</strong>
|
||||
</div>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-2 rounded font-mono text-bolt-elements-textPrimary">
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Model Recommendations */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
2. Download Latest Models
|
||||
</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
|
||||
<Code className="w-4 h-4 text-green-500" />
|
||||
Code & Development
|
||||
</h5>
|
||||
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
|
||||
<div># Latest Llama 3.2 for coding</div>
|
||||
<div>ollama pull llama3.2:3b</div>
|
||||
<div>ollama pull codellama:13b</div>
|
||||
<div>ollama pull deepseek-coder-v2</div>
|
||||
<div>ollama pull qwen2.5-coder:7b</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-blue-500" />
|
||||
General Purpose & Chat
|
||||
</h5>
|
||||
<div className="space-y-2 text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary">
|
||||
<div># Latest general models</div>
|
||||
<div>ollama pull llama3.2:3b</div>
|
||||
<div>ollama pull mistral:7b</div>
|
||||
<div>ollama pull phi3.5:3.8b</div>
|
||||
<div>ollama pull qwen2.5:7b</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-purple-500/5 border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-purple-500" />
|
||||
<span className="font-medium text-purple-500">Performance Optimized</span>
|
||||
</div>
|
||||
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||
<li>• Llama 3.2: 3B - Fastest, 8GB RAM</li>
|
||||
<li>• Phi-3.5: 3.8B - Great balance</li>
|
||||
<li>• Qwen2.5: 7B - Excellent quality</li>
|
||||
<li>• Mistral: 7B - Popular choice</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
<span className="font-medium text-yellow-500">Pro Tips</span>
|
||||
</div>
|
||||
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||
<li>• Start with 3B-7B models for best performance</li>
|
||||
<li>• Use quantized versions for faster loading</li>
|
||||
<li>• Desktop app auto-manages model storage</li>
|
||||
<li>• Web UI available at localhost:11434</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop App Features */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
3. Desktop App Features
|
||||
</h4>
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h5 className="font-medium text-blue-500 mb-3">🖥️ User Interface</h5>
|
||||
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
||||
<li>• Model library browser</li>
|
||||
<li>• One-click model downloads</li>
|
||||
<li>• Built-in chat interface</li>
|
||||
<li>• System resource monitoring</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-blue-500 mb-3">🔧 Management Tools</h5>
|
||||
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
||||
<li>• Automatic updates</li>
|
||||
<li>• Model size optimization</li>
|
||||
<li>• GPU acceleration detection</li>
|
||||
<li>• Cross-platform compatibility</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
4. Troubleshooting & Commands
|
||||
</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-red-500/5 border border-red-500/20">
|
||||
<h5 className="font-medium text-red-500 mb-2">Common Issues</h5>
|
||||
<ul className="text-xs text-bolt-elements-textSecondary space-y-1">
|
||||
<li>• Desktop app not starting: Restart system</li>
|
||||
<li>• GPU not detected: Update drivers</li>
|
||||
<li>• Port 11434 blocked: Change port in settings</li>
|
||||
<li>• Models not loading: Check available disk space</li>
|
||||
<li>• Slow performance: Use smaller models or enable GPU</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20">
|
||||
<h5 className="font-medium text-green-500 mb-2">Useful Commands</h5>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||
<div># Check installed models</div>
|
||||
<div>ollama list</div>
|
||||
<div></div>
|
||||
<div># Remove unused models</div>
|
||||
<div>ollama rm model_name</div>
|
||||
<div></div>
|
||||
<div># Check GPU usage</div>
|
||||
<div>ollama ps</div>
|
||||
<div></div>
|
||||
<div># View logs</div>
|
||||
<div>ollama logs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LM Studio Setup Section */}
|
||||
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/20 flex items-center justify-center ring-1 ring-blue-500/30">
|
||||
<Monitor className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LM Studio Setup</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
User-friendly GUI for running local models with excellent model management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Installation */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
1. Download & Install
|
||||
</h4>
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||
Download LM Studio for Windows, macOS, or Linux from the official website.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-blue-500/10 to-blue-600/10 hover:from-blue-500/20 hover:to-blue-600/20 border-blue-500/30 hover:border-blue-500/50 transition-all duration-300 gap-2 group shadow-sm hover:shadow-lg hover:shadow-blue-500/20 font-medium"
|
||||
_asChild
|
||||
>
|
||||
<a
|
||||
href="https://lmstudio.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4 group-hover:scale-110 group-hover:rotate-12 transition-all duration-300 flex-shrink-0" />
|
||||
<span className="flex-1 text-center font-medium">Download LM Studio</span>
|
||||
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300 flex-shrink-0" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
2. Configure Local Server
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Start Local Server</h5>
|
||||
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside">
|
||||
<li>Download a model from the "My Models" tab</li>
|
||||
<li>Go to "Local Server" tab</li>
|
||||
<li>Select your downloaded model</li>
|
||||
<li>Set port to 1234 (default)</li>
|
||||
<li>Click "Start Server"</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="font-medium text-red-500">Critical: Enable CORS</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
To work with Bolt DIY, you MUST enable CORS in LM Studio:
|
||||
</p>
|
||||
<ol className="text-xs text-bolt-elements-textSecondary space-y-1 list-decimal list-inside ml-2">
|
||||
<li>In Server Settings, check "Enable CORS"</li>
|
||||
<li>Set Network Interface to "0.0.0.0" for external access</li>
|
||||
<li>
|
||||
Alternatively, use CLI:{' '}
|
||||
<code className="bg-bolt-elements-background-depth-4 px-1 rounded">lms server start --cors</code>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advantages */}
|
||||
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-medium text-blue-500">LM Studio Advantages</span>
|
||||
</div>
|
||||
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
|
||||
<li>Built-in model downloader with search</li>
|
||||
<li>Easy model switching and management</li>
|
||||
<li>Built-in chat interface for testing</li>
|
||||
<li>GGUF format support (most compatible)</li>
|
||||
<li>Regular updates with new features</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LocalAI Setup Section */}
|
||||
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/20 flex items-center justify-center ring-1 ring-green-500/30">
|
||||
<Globe className="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">LocalAI Setup</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Self-hosted OpenAI-compatible API server with extensive model support
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Installation */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Installation Options
|
||||
</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Quick Install</h5>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||
<div># One-line install</div>
|
||||
<div>curl https://localai.io/install.sh | sh</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h5 className="font-medium text-bolt-elements-textPrimary mb-2">Docker (Recommended)</h5>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||
<div>docker run -p 8080:8080</div>
|
||||
<div>quay.io/go-skynet/local-ai:latest</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configuration
|
||||
</h4>
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-3">
|
||||
LocalAI supports many model formats and provides a full OpenAI-compatible API.
|
||||
</p>
|
||||
<div className="text-xs bg-bolt-elements-background-depth-4 p-3 rounded font-mono text-bolt-elements-textPrimary space-y-1">
|
||||
<div># Example configuration</div>
|
||||
<div>models:</div>
|
||||
<div>- name: llama3.1</div>
|
||||
<div>backend: llama</div>
|
||||
<div>parameters:</div>
|
||||
<div>model: llama3.1.gguf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advantages */}
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="font-medium text-green-500">LocalAI Advantages</span>
|
||||
</div>
|
||||
<ul className="text-xs text-bolt-elements-textSecondary space-y-1 list-disc list-inside">
|
||||
<li>Full OpenAI API compatibility</li>
|
||||
<li>Supports multiple model formats</li>
|
||||
<li>Docker deployment option</li>
|
||||
<li>Built-in model gallery</li>
|
||||
<li>REST API for model management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Optimization */}
|
||||
<Card className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<Activity className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Performance Optimization</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Tips to improve local AI performance</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary">Hardware Optimizations</h4>
|
||||
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Use NVIDIA GPU with CUDA for 5-10x speedup</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Increase RAM for larger context windows</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Use SSD storage for faster model loading</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Close other applications to free up RAM</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary">Software Optimizations</h4>
|
||||
<ul className="text-sm text-bolt-elements-textSecondary space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Use smaller models for faster responses</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Enable quantization (4-bit, 8-bit models)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Reduce context length for chat applications</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Use streaming responses for better UX</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alternative Options */}
|
||||
<Card className="bg-bolt-elements-background-depth-2 shadow-sm">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500/20 to-red-500/20 flex items-center justify-center ring-1 ring-orange-500/30">
|
||||
<Wifi className="w-6 h-6 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-bolt-elements-textPrimary">Alternative Options</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Other local AI solutions and cloud alternatives
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary">Other Local Solutions</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">Jan.ai</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
Modern interface with built-in model marketplace
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Terminal className="w-4 h-4 text-green-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">Oobabooga</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">
|
||||
Advanced text generation web UI with extensions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Cable className="w-4 h-4 text-purple-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">KoboldAI</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Focus on creative writing and storytelling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-bolt-elements-textPrimary">Cloud Alternatives</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Globe className="w-4 h-4 text-orange-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">OpenRouter</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Access to 100+ models through unified API</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Server className="w-4 h-4 text-red-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">Together AI</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Fast inference with open-source models</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-4 h-4 text-pink-500" />
|
||||
<span className="font-medium text-bolt-elements-textPrimary">Groq</span>
|
||||
</div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Ultra-fast LPU inference for Llama models</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupGuide;
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card, CardContent } from '~/components/ui/Card';
|
||||
import { Cable, Server, ArrowLeft } from 'lucide-react';
|
||||
import { useLocalModelHealth } from '~/lib/hooks/useLocalModelHealth';
|
||||
import HealthStatusBadge from './HealthStatusBadge';
|
||||
import { PROVIDER_ICONS } from './types';
|
||||
|
||||
// Status Dashboard Component
|
||||
function StatusDashboard({ onBack }: { onBack: () => void }) {
|
||||
const { healthStatuses } = useLocalModelHealth();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Back Button */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="bg-transparent hover:bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-all duration-200 p-2"
|
||||
aria-label="Back to Dashboard"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-bolt-elements-textPrimary">Provider Status</h2>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Monitor the health of your local AI providers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{healthStatuses.length === 0 ? (
|
||||
<Card className="bg-bolt-elements-background-depth-2">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Cable className="w-16 h-16 mx-auto text-bolt-elements-textTertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-2">No Endpoints Configured</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Configure and enable local providers to see their endpoint status here.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{healthStatuses.map((status) => (
|
||||
<Card key={`${status.provider}-${status.baseUrl}`} className="bg-bolt-elements-background-depth-2">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-bolt-elements-background-depth-3 flex items-center justify-center">
|
||||
{React.createElement(PROVIDER_ICONS[status.provider as keyof typeof PROVIDER_ICONS] || Server, {
|
||||
className: 'w-5 h-5 text-bolt-elements-textPrimary',
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-bolt-elements-textPrimary">{status.provider}</h3>
|
||||
<p className="text-xs text-bolt-elements-textSecondary font-mono">{status.baseUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HealthStatusBadge status={status.status} responseTime={status.responseTime} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-bolt-elements-textSecondary">Models</div>
|
||||
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
{status.availableModels?.length || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-bolt-elements-textSecondary">Version</div>
|
||||
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
{status.version || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-bolt-elements-textSecondary">Last Check</div>
|
||||
<div className="text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
{status.lastChecked ? new Date(status.lastChecked).toLocaleTimeString() : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusDashboard;
|
||||
@@ -1,44 +0,0 @@
|
||||
// Type definitions
|
||||
export type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
digest: string;
|
||||
size: number;
|
||||
modified_at: string;
|
||||
details?: {
|
||||
family: string;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
|
||||
error?: string;
|
||||
newDigest?: string;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LMStudioModel {
|
||||
id: string;
|
||||
object: 'model';
|
||||
owned_by: string;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
// Constants
|
||||
export const OLLAMA_API_URL = 'http://127.0.0.1:11434';
|
||||
|
||||
export const PROVIDER_ICONS = {
|
||||
Ollama: 'Server',
|
||||
LMStudio: 'Monitor',
|
||||
OpenAILike: 'Globe',
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_DESCRIPTIONS = {
|
||||
Ollama: 'Run open-source models locally on your machine',
|
||||
LMStudio: 'Local model inference with LM Studio',
|
||||
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
|
||||
} as const;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ServiceStatus } from './types';
|
||||
import { ProviderStatusCheckerFactory } from './provider-factory';
|
||||
|
||||
export default function ServiceStatusTab() {
|
||||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAllProviders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const providers = ProviderStatusCheckerFactory.getProviderNames();
|
||||
const statuses: ServiceStatus[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const checker = ProviderStatusCheckerFactory.getChecker(provider);
|
||||
const result = await checker.checkStatus();
|
||||
|
||||
statuses.push({
|
||||
provider,
|
||||
...result,
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error checking ${provider} status:`, err);
|
||||
statuses.push({
|
||||
provider,
|
||||
status: 'degraded',
|
||||
message: 'Unable to check service status',
|
||||
incidents: ['Error checking service status'],
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setServiceStatuses(statuses);
|
||||
} catch (err) {
|
||||
console.error('Error checking provider statuses:', err);
|
||||
setError('Failed to check service statuses');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAllProviders();
|
||||
|
||||
// Set up periodic checks every 5 minutes
|
||||
const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: ServiceStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
case 'degraded':
|
||||
return 'text-yellow-500 dark:text-yellow-400';
|
||||
case 'down':
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
default:
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ServiceStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'i-ph:check-circle';
|
||||
case 'degraded':
|
||||
return 'i-ph:warning';
|
||||
case 'down':
|
||||
return 'i-ph:x-circle';
|
||||
default:
|
||||
return 'i-ph:question';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
|
||||
<div className="i-ph:warning w-8 h-8 mb-2" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{serviceStatuses.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
|
||||
<div className={`flex items-center ${getStatusColor(service.status)}`}>
|
||||
<div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
|
||||
<span className="capitalize">{service.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
|
||||
{service.incidents && service.incidents.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
{service.incidents.map((incident, index) => (
|
||||
<li key={index}>{incident}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Last checked: {new Date(service.lastChecked).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
|
||||
|
||||
export abstract class BaseProviderChecker {
|
||||
protected config: ProviderConfig;
|
||||
|
||||
constructor(config: ProviderConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected async checkApiEndpoint(
|
||||
url: string,
|
||||
headers?: Record<string, string>,
|
||||
testModel?: string,
|
||||
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Add common headers
|
||||
const processedHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: processedHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const data = (await response.json()) as ApiResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `API returned status: ${response.status}`;
|
||||
|
||||
if (data.error?.message) {
|
||||
errorMessage = data.error.message;
|
||||
} else if (data.message) {
|
||||
errorMessage = data.message;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
message: errorMessage,
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Different providers have different model list formats
|
||||
let models: string[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
|
||||
} else if (data.data && Array.isArray(data.data)) {
|
||||
models = data.data.map((model) => model.id || model.name || '');
|
||||
} else if (data.models && Array.isArray(data.models)) {
|
||||
models = data.models.map((model) => model.id || model.name || '');
|
||||
} else if (data.model) {
|
||||
models = [data.model];
|
||||
}
|
||||
|
||||
if (!testModel || models.length > 0) {
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
responseTime,
|
||||
message: 'API key is valid',
|
||||
};
|
||||
}
|
||||
|
||||
if (testModel && !models.includes(testModel)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'model_not_found',
|
||||
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
message: 'API key is valid',
|
||||
responseTime,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error checking API endpoint ${url}:`, error);
|
||||
return {
|
||||
ok: false,
|
||||
status: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
|
||||
responseTime: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'no-cors',
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
});
|
||||
return response.type === 'opaque' ? 'reachable' : 'unreachable';
|
||||
} catch (error) {
|
||||
console.error(`Error checking ${url}:`, error);
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
abstract checkStatus(): Promise<StatusCheckResult>;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
|
||||
import { BaseProviderChecker } from './base-provider';
|
||||
|
||||
import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
|
||||
import { CohereStatusChecker } from './providers/cohere';
|
||||
import { DeepseekStatusChecker } from './providers/deepseek';
|
||||
import { GoogleStatusChecker } from './providers/google';
|
||||
import { GroqStatusChecker } from './providers/groq';
|
||||
import { HuggingFaceStatusChecker } from './providers/huggingface';
|
||||
import { HyperbolicStatusChecker } from './providers/hyperbolic';
|
||||
import { MistralStatusChecker } from './providers/mistral';
|
||||
import { OpenRouterStatusChecker } from './providers/openrouter';
|
||||
import { PerplexityStatusChecker } from './providers/perplexity';
|
||||
import { TogetherStatusChecker } from './providers/together';
|
||||
import { XAIStatusChecker } from './providers/xai';
|
||||
|
||||
export class ProviderStatusCheckerFactory {
|
||||
private static _providerConfigs: Record<ProviderName, ProviderConfig> = {
|
||||
AmazonBedrock: {
|
||||
statusUrl: 'https://health.aws.amazon.com/health/status',
|
||||
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
|
||||
headers: {},
|
||||
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
|
||||
},
|
||||
Cohere: {
|
||||
statusUrl: 'https://status.cohere.com/',
|
||||
apiUrl: 'https://api.cohere.ai/v1/models',
|
||||
headers: {},
|
||||
testModel: 'command',
|
||||
},
|
||||
Deepseek: {
|
||||
statusUrl: 'https://status.deepseek.com/',
|
||||
apiUrl: 'https://api.deepseek.com/v1/models',
|
||||
headers: {},
|
||||
testModel: 'deepseek-chat',
|
||||
},
|
||||
Google: {
|
||||
statusUrl: 'https://status.cloud.google.com/',
|
||||
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
|
||||
headers: {},
|
||||
testModel: 'gemini-pro',
|
||||
},
|
||||
Groq: {
|
||||
statusUrl: 'https://groqstatus.com/',
|
||||
apiUrl: 'https://api.groq.com/v1/models',
|
||||
headers: {},
|
||||
testModel: 'mixtral-8x7b-32768',
|
||||
},
|
||||
HuggingFace: {
|
||||
statusUrl: 'https://status.huggingface.co/',
|
||||
apiUrl: 'https://api-inference.huggingface.co/models',
|
||||
headers: {},
|
||||
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
||||
},
|
||||
Hyperbolic: {
|
||||
statusUrl: 'https://status.hyperbolic.ai/',
|
||||
apiUrl: 'https://api.hyperbolic.ai/v1/models',
|
||||
headers: {},
|
||||
testModel: 'hyperbolic-1',
|
||||
},
|
||||
Mistral: {
|
||||
statusUrl: 'https://status.mistral.ai/',
|
||||
apiUrl: 'https://api.mistral.ai/v1/models',
|
||||
headers: {},
|
||||
testModel: 'mistral-tiny',
|
||||
},
|
||||
OpenRouter: {
|
||||
statusUrl: 'https://status.openrouter.ai/',
|
||||
apiUrl: 'https://openrouter.ai/api/v1/models',
|
||||
headers: {},
|
||||
testModel: 'anthropic/claude-3-sonnet',
|
||||
},
|
||||
Perplexity: {
|
||||
statusUrl: 'https://status.perplexity.com/',
|
||||
apiUrl: 'https://api.perplexity.ai/v1/models',
|
||||
headers: {},
|
||||
testModel: 'pplx-7b-chat',
|
||||
},
|
||||
Together: {
|
||||
statusUrl: 'https://status.together.ai/',
|
||||
apiUrl: 'https://api.together.xyz/v1/models',
|
||||
headers: {},
|
||||
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
||||
},
|
||||
XAI: {
|
||||
statusUrl: 'https://status.x.ai/',
|
||||
apiUrl: 'https://api.x.ai/v1/models',
|
||||
headers: {},
|
||||
testModel: 'grok-1',
|
||||
},
|
||||
};
|
||||
|
||||
static getChecker(provider: ProviderName): BaseProviderChecker {
|
||||
const config = this._providerConfigs[provider];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`No configuration found for provider: ${provider}`);
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'AmazonBedrock':
|
||||
return new AmazonBedrockStatusChecker(config);
|
||||
case 'Cohere':
|
||||
return new CohereStatusChecker(config);
|
||||
case 'Deepseek':
|
||||
return new DeepseekStatusChecker(config);
|
||||
case 'Google':
|
||||
return new GoogleStatusChecker(config);
|
||||
case 'Groq':
|
||||
return new GroqStatusChecker(config);
|
||||
case 'HuggingFace':
|
||||
return new HuggingFaceStatusChecker(config);
|
||||
case 'Hyperbolic':
|
||||
return new HyperbolicStatusChecker(config);
|
||||
case 'Mistral':
|
||||
return new MistralStatusChecker(config);
|
||||
case 'OpenRouter':
|
||||
return new OpenRouterStatusChecker(config);
|
||||
case 'Perplexity':
|
||||
return new PerplexityStatusChecker(config);
|
||||
case 'Together':
|
||||
return new TogetherStatusChecker(config);
|
||||
case 'XAI':
|
||||
return new XAIStatusChecker(config);
|
||||
default:
|
||||
return new (class extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
|
||||
const apiStatus = await this.checkEndpoint(this.config.apiUrl);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
}
|
||||
|
||||
static getProviderNames(): ProviderName[] {
|
||||
return Object.keys(this._providerConfigs) as ProviderName[];
|
||||
}
|
||||
|
||||
static getProviderConfig(provider: ProviderName): ProviderConfig {
|
||||
const config = this._providerConfigs[provider];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class AmazonBedrockStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check AWS health status page
|
||||
const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check for Bedrock and general AWS status
|
||||
const hasBedrockIssues =
|
||||
text.includes('Amazon Bedrock') &&
|
||||
(text.includes('Service is experiencing elevated error rates') ||
|
||||
text.includes('Service disruption') ||
|
||||
text.includes('Degraded Service'));
|
||||
|
||||
const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
||||
|
||||
for (const match of incidentMatches) {
|
||||
const [, date, title, impact] = match;
|
||||
|
||||
if (title.includes('Bedrock') || title.includes('AWS')) {
|
||||
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All services operational';
|
||||
|
||||
if (hasBedrockIssues) {
|
||||
status = 'degraded';
|
||||
message = 'Amazon Bedrock service issues reported';
|
||||
} else if (hasGeneralIssues) {
|
||||
status = 'degraded';
|
||||
message = 'AWS experiencing general issues';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
||||
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: incidents.slice(0, 5),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Amazon Bedrock status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
||||
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class AnthropicStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.anthropic.com/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check for specific Anthropic status indicators
|
||||
const isOperational = text.includes('All Systems Operational');
|
||||
const hasDegradedPerformance = text.includes('Degraded Performance');
|
||||
const hasPartialOutage = text.includes('Partial Outage');
|
||||
const hasMajorOutage = text.includes('Major Outage');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
||||
|
||||
if (incidentSection) {
|
||||
const incidentLines = incidentSection[1]
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
||||
|
||||
incidents.push(...incidentLines.slice(0, 5));
|
||||
}
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (hasMajorOutage) {
|
||||
status = 'down';
|
||||
message = 'Major service outage';
|
||||
} else if (hasPartialOutage) {
|
||||
status = 'down';
|
||||
message = 'Partial service outage';
|
||||
} else if (hasDegradedPerformance) {
|
||||
status = 'degraded';
|
||||
message = 'Service experiencing degraded performance';
|
||||
} else if (!isOperational) {
|
||||
status = 'degraded';
|
||||
message = 'Service status unknown';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
||||
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Anthropic status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
||||
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class CohereStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.cohere.com/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check for specific Cohere status indicators
|
||||
const isOperational = text.includes('All Systems Operational');
|
||||
const hasIncidents = text.includes('Active Incidents');
|
||||
const hasDegradation = text.includes('Degraded Performance');
|
||||
const hasOutage = text.includes('Service Outage');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
||||
|
||||
if (incidentSection) {
|
||||
const incidentLines = incidentSection[1]
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
||||
|
||||
incidents.push(...incidentLines.slice(0, 5));
|
||||
}
|
||||
|
||||
// Check specific services
|
||||
const services = {
|
||||
api: {
|
||||
operational: text.includes('API Service') && text.includes('Operational'),
|
||||
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
||||
outage: text.includes('API Service') && text.includes('Service Outage'),
|
||||
},
|
||||
generation: {
|
||||
operational: text.includes('Generation Service') && text.includes('Operational'),
|
||||
degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
|
||||
outage: text.includes('Generation Service') && text.includes('Service Outage'),
|
||||
},
|
||||
};
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (services.api.outage || services.generation.outage || hasOutage) {
|
||||
status = 'down';
|
||||
message = 'Service outage detected';
|
||||
} else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
|
||||
status = 'degraded';
|
||||
message = 'Service experiencing issues';
|
||||
} else if (!isOperational) {
|
||||
status = 'degraded';
|
||||
message = 'Service status unknown';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
||||
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Cohere status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
||||
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class DeepseekStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
/*
|
||||
* Check status page - Note: Deepseek doesn't have a public status page yet
|
||||
* so we'll check their API endpoint directly
|
||||
*/
|
||||
const apiEndpoint = 'https://api.deepseek.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
// Check their website as a secondary indicator
|
||||
const websiteStatus = await this.checkEndpoint('https://deepseek.com');
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
||||
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
||||
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: [], // No public incident tracking available yet
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Deepseek status:', error);
|
||||
|
||||
return {
|
||||
status: 'degraded',
|
||||
message: 'Unable to determine service status',
|
||||
incidents: ['Note: Limited status information available'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class GoogleStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.cloud.google.com/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check for Vertex AI and general cloud status
|
||||
const hasVertexAIIssues =
|
||||
text.includes('Vertex AI') &&
|
||||
(text.includes('Incident') ||
|
||||
text.includes('Disruption') ||
|
||||
text.includes('Outage') ||
|
||||
text.includes('degraded'));
|
||||
|
||||
const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
||||
|
||||
for (const match of incidentMatches) {
|
||||
const [, date, title, impact] = match;
|
||||
|
||||
if (title.includes('Vertex AI') || title.includes('Cloud')) {
|
||||
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All services operational';
|
||||
|
||||
if (hasVertexAIIssues) {
|
||||
status = 'degraded';
|
||||
message = 'Vertex AI service issues reported';
|
||||
} else if (hasGeneralIssues) {
|
||||
status = 'degraded';
|
||||
message = 'Google Cloud experiencing issues';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
||||
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: incidents.slice(0, 5),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Google status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
||||
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class GroqStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://groqstatus.com/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
const isOperational = text.includes('All Systems Operational');
|
||||
const hasIncidents = text.includes('Active Incidents');
|
||||
const hasDegradation = text.includes('Degraded Performance');
|
||||
const hasOutage = text.includes('Service Outage');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
|
||||
|
||||
for (const match of incidentMatches) {
|
||||
const [, date, title, status] = match;
|
||||
incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
|
||||
}
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (hasOutage) {
|
||||
status = 'down';
|
||||
message = 'Service outage detected';
|
||||
} else if (hasDegradation || hasIncidents) {
|
||||
status = 'degraded';
|
||||
message = 'Service experiencing issues';
|
||||
} else if (!isOperational) {
|
||||
status = 'degraded';
|
||||
message = 'Service status unknown';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
||||
const apiEndpoint = 'https://api.groq.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: incidents.slice(0, 5),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Groq status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
||||
const apiEndpoint = 'https://api.groq.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class HuggingFaceStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.huggingface.co/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check for "All services are online" message
|
||||
const allServicesOnline = text.includes('All services are online');
|
||||
|
||||
// Get last update time
|
||||
const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
|
||||
const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
|
||||
|
||||
// Check individual services and their uptime percentages
|
||||
const services = {
|
||||
'Huggingface Hub': {
|
||||
operational: text.includes('Huggingface Hub') && text.includes('Operational'),
|
||||
uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
||||
},
|
||||
'Git Hosting and Serving': {
|
||||
operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
|
||||
uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
||||
},
|
||||
'Inference API': {
|
||||
operational: text.includes('Inference API') && text.includes('Operational'),
|
||||
uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
||||
},
|
||||
'HF Endpoints': {
|
||||
operational: text.includes('HF Endpoints') && text.includes('Operational'),
|
||||
uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
||||
},
|
||||
Spaces: {
|
||||
operational: text.includes('Spaces') && text.includes('Operational'),
|
||||
uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
||||
},
|
||||
};
|
||||
|
||||
// Create service status messages with uptime
|
||||
const serviceMessages = Object.entries(services).map(([name, info]) => {
|
||||
if (info.uptime) {
|
||||
return `${name}: ${info.uptime}% uptime`;
|
||||
}
|
||||
|
||||
return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
|
||||
});
|
||||
|
||||
// Determine overall status
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = allServicesOnline
|
||||
? `All services are online (Last updated on ${lastUpdate})`
|
||||
: 'Checking individual services';
|
||||
|
||||
// Only mark as degraded if we explicitly detect issues
|
||||
const hasIssues = Object.values(services).some((service) => !service.operational);
|
||||
|
||||
if (hasIssues) {
|
||||
status = 'degraded';
|
||||
message = `Service issues detected (Last updated on ${lastUpdate})`;
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
||||
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: serviceMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking HuggingFace status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
||||
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class HyperbolicStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
/*
|
||||
* Check API endpoint directly since Hyperbolic is a newer provider
|
||||
* and may not have a public status page yet
|
||||
*/
|
||||
const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
// Check their website as a secondary indicator
|
||||
const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
||||
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
||||
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents: [], // No public incident tracking available yet
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Hyperbolic status:', error);
|
||||
|
||||
return {
|
||||
status: 'degraded',
|
||||
message: 'Unable to determine service status',
|
||||
incidents: ['Note: Limited status information available'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class MistralStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.mistral.ai/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
const isOperational = text.includes('All Systems Operational');
|
||||
const hasIncidents = text.includes('Active Incidents');
|
||||
const hasDegradation = text.includes('Degraded Performance');
|
||||
const hasOutage = text.includes('Service Outage');
|
||||
|
||||
// Extract incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
|
||||
|
||||
if (incidentSection) {
|
||||
const incidentLines = incidentSection[1]
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.includes('No incidents'));
|
||||
|
||||
incidents.push(...incidentLines.slice(0, 5));
|
||||
}
|
||||
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
let message = 'All systems operational';
|
||||
|
||||
if (hasOutage) {
|
||||
status = 'down';
|
||||
message = 'Service outage detected';
|
||||
} else if (hasDegradation || hasIncidents) {
|
||||
status = 'degraded';
|
||||
message = 'Service experiencing issues';
|
||||
} else if (!isOperational) {
|
||||
status = 'degraded';
|
||||
message = 'Service status unknown';
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
|
||||
const apiEndpoint = 'https://api.mistral.ai/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
incidents,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking Mistral status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
|
||||
const apiEndpoint = 'https://api.mistral.ai/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
||||
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
||||
|
||||
export class OpenAIStatusChecker extends BaseProviderChecker {
|
||||
async checkStatus(): Promise<StatusCheckResult> {
|
||||
try {
|
||||
// Check status page
|
||||
const statusPageResponse = await fetch('https://status.openai.com/');
|
||||
const text = await statusPageResponse.text();
|
||||
|
||||
// Check individual services
|
||||
const services = {
|
||||
api: {
|
||||
operational: text.includes('API ? Operational'),
|
||||
degraded: text.includes('API ? Degraded Performance'),
|
||||
outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
|
||||
},
|
||||
chat: {
|
||||
operational: text.includes('ChatGPT ? Operational'),
|
||||
degraded: text.includes('ChatGPT ? Degraded Performance'),
|
||||
outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
|
||||
},
|
||||
};
|
||||
|
||||
// Extract recent incidents
|
||||
const incidents: string[] = [];
|
||||
const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
|
||||
|
||||
if (incidentMatches) {
|
||||
const recentIncidents = incidentMatches[1]
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line.includes('202')); // Get only dated incidents
|
||||
|
||||
incidents.push(...recentIncidents.slice(0, 5));
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
let status: StatusCheckResult['status'] = 'operational';
|
||||
const messages: string[] = [];
|
||||
|
||||
if (services.api.outage || services.chat.outage) {
|
||||
status = 'down';
|
||||
|
||||
if (services.api.outage) {
|
||||
messages.push('API: Major Outage');
|
||||
}
|
||||
|
||||
if (services.chat.outage) {
|
||||
messages.push('ChatGPT: Major Outage');
|
||||
}
|
||||
} else if (services.api.degraded || services.chat.degraded) {
|
||||
status = 'degraded';
|
||||
|
||||
if (services.api.degraded) {
|
||||
messages.push('API: Degraded Performance');
|
||||
}
|
||||
|
||||
if (services.chat.degraded) {
|
||||
messages.push('ChatGPT: Degraded Performance');
|
||||
}
|
||||
} else if (services.api.operational) {
|
||||
messages.push('API: Operational');
|
||||
}
|
||||
|
||||
// If status page check fails, fallback to endpoint check
|
||||
if (!statusPageResponse.ok) {
|
||||
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
|
||||
const apiEndpoint = 'https://api.openai.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
message: messages.join(', ') || 'Status unknown',
|
||||
incidents,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI status:', error);
|
||||
|
||||
// Fallback to basic endpoint check
|
||||
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
|
||||
const apiEndpoint = 'https://api.openai.com/v1/models';
|
||||
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
||||
|
||||
return {
|
||||
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
||||
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
||||
incidents: ['Note: Limited status information due to CORS restrictions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user