feat: new improvement for the GitHub API Authentication Fix (#1537)
* Add environment variables section to ConnectionsTab and fallback token to git-info * Add remaining code from original branch * Import Repo Fix * refactor the UI * add a rate limit counter * Update GithubConnection.tsx * Update NetlifyConnection.tsx * fix: ui style * Sync with upstream and preserve GitHub connection and DataTab fixes * fix disconnect buttons * revert commits * Update api.git-proxy.$.ts * Update api.git-proxy.$.ts
This commit is contained in:
16
.env.example
16
.env.example
@@ -97,6 +97,22 @@ AWS_BEDROCK_CONFIG=
|
|||||||
# Include this environment variable if you want more logging for debugging locally
|
# Include this environment variable if you want more logging for debugging locally
|
||||||
VITE_LOG_LEVEL=debug
|
VITE_LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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
|
# Example Context Values for qwen2.5-coder:32b
|
||||||
#
|
#
|
||||||
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
||||||
|
|||||||
115
.env.production
Normal file
115
.env.production
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Rename this file to .env once you have filled in the below environment variables!
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# You only need this environment variable set if you want to use OpenAI Like models
|
||||||
|
OPENAI_LIKE_API_BASE_URL=
|
||||||
|
|
||||||
|
# You only need this environment variable set if you want to use Together AI models
|
||||||
|
TOGETHER_API_BASE_URL=
|
||||||
|
|
||||||
|
# You only need this environment variable set if you want to use DeepSeek models through their API
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
|
||||||
|
# Get your OpenAI Like API Key
|
||||||
|
OPENAI_LIKE_API_KEY=
|
||||||
|
|
||||||
|
# Get your Together API Key
|
||||||
|
TOGETHER_API_KEY=
|
||||||
|
|
||||||
|
# You only need this environment variable set if you want to use Hyperbolic models
|
||||||
|
HYPERBOLIC_API_KEY=
|
||||||
|
HYPERBOLIC_API_BASE_URL=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# Get your AWS configuration
|
||||||
|
# https://console.aws.amazon.com/iam/home
|
||||||
|
AWS_BEDROCK_CONFIG=
|
||||||
|
|
||||||
|
# Include this environment variable if you want more logging for debugging locally
|
||||||
|
VITE_LOG_LEVEL=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# Netlify Authentication
|
||||||
|
VITE_NETLIFY_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# 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=
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,6 +25,7 @@ dist-ssr
|
|||||||
/.history
|
/.history
|
||||||
/.cache
|
/.cache
|
||||||
/build
|
/build
|
||||||
|
functions/build/
|
||||||
.env.local
|
.env.local
|
||||||
.env
|
.env
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Get GitHub token if available
|
||||||
|
const githubToken = localStorageChecks.githubConnection
|
||||||
|
? JSON.parse(localStorageChecks.githubConnection)?.token
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const authHeaders = {
|
||||||
|
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
|
||||||
|
|
||||||
|
// Test GitHub API endpoints
|
||||||
|
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: authHeaders,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if Netlify token works
|
||||||
|
let netlifyUserCheck = null;
|
||||||
|
const netlifyToken = localStorageChecks.netlifyConnection
|
||||||
|
? JSON.parse(localStorageChecks.netlifyConnection || '{"token":""}').token
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile results
|
||||||
|
const results = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
localStorage: {
|
||||||
|
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
|
||||||
|
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
|
||||||
|
githubConnectionParsed: localStorageChecks.githubConnection
|
||||||
|
? JSON.parse(localStorageChecks.githubConnection)
|
||||||
|
: null,
|
||||||
|
netlifyConnectionParsed: localStorageChecks.netlifyConnection
|
||||||
|
? JSON.parse(localStorageChecks.netlifyConnection)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
apiEndpoints: {
|
||||||
|
github: githubResults,
|
||||||
|
netlify: netlifyUserCheck,
|
||||||
|
},
|
||||||
|
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.hasGithubConnection && !results.localStorage.hasNetlifyConnection) {
|
||||||
|
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.');
|
||||||
|
} 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.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing Netlify data:', error);
|
||||||
|
toast.error('Failed to clear Netlify 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-si: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>
|
||||||
|
</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}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="i-ph:github-logo w-4 h-4" />
|
||||||
|
Reset GitHub Connection
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={resetNetlifyConnection}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="i-si:netlify w-4 h-4" />
|
||||||
|
Reset Netlify Connection
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +1,180 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import React, { Suspense } from 'react';
|
import React, { Suspense, useState } from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import ConnectionDiagnostics from './ConnectionDiagnostics';
|
||||||
|
import { Button } from '~/components/ui/Button';
|
||||||
|
|
||||||
// Use React.lazy for dynamic imports
|
// Use React.lazy for dynamic imports
|
||||||
const GithubConnection = React.lazy(() => import('./GithubConnection'));
|
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
|
||||||
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
|
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div className="p-4 bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
<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 gap-2 text-bolt-elements-textSecondary">
|
<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-5 h-5 animate-spin" />
|
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||||
<span>Loading connection...</span>
|
<span>Loading connection...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function ConnectionsTab() {
|
export default function ConnectionsTab() {
|
||||||
|
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
|
||||||
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex items-center gap-2 mb-2"
|
className="flex items-center justify-between gap-2"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
<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>
|
</motion.div>
|
||||||
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
Manage your external service connections and integrations
|
Manage your external service connections and integrations
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
{/* 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 />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<GithubConnection />
|
<GitHubConnection />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<NetlifyConnection />
|
<NetlifyConnection />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,263 +1,755 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useStore } from '@nanostores/react';
|
|
||||||
import { logStore } from '~/lib/stores/logs';
|
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
|
||||||
|
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
|
||||||
import {
|
import {
|
||||||
netlifyConnection,
|
CloudIcon,
|
||||||
isConnecting,
|
BuildingLibraryIcon,
|
||||||
isFetchingStats,
|
ClockIcon,
|
||||||
updateNetlifyConnection,
|
CodeBracketIcon,
|
||||||
fetchNetlifyStats,
|
CheckCircleIcon,
|
||||||
} from '~/lib/stores/netlify';
|
XCircleIcon,
|
||||||
import type { NetlifyUser } from '~/types/netlify';
|
TrashIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
LockOpenIcon,
|
||||||
|
RocketLaunchIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { Button } from '~/components/ui/Button';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Badge } from '~/components/ui/Badge';
|
||||||
|
|
||||||
|
// Add the Netlify logo SVG component at the top of the file
|
||||||
|
const NetlifyLogo = () => (
|
||||||
|
<svg viewBox="0 0 40 40" className="w-5 h-5">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M28.589 14.135l-.014-.006c-.008-.003-.016-.006-.023-.013a.11.11 0 0 1-.028-.093l.773-4.726 3.625 3.626-3.77 1.604a.083.083 0 0 1-.033.006h-.015c-.005-.003-.01-.007-.02-.017a1.716 1.716 0 0 0-.495-.381zm5.258-.288l3.876 3.876c.805.806 1.208 1.208 1.674 1.355a2 2 0 0 1 1.206 0c.466-.148.869-.55 1.674-1.356L8.73 28.73l2.349-3.643c.011-.018.022-.034.04-.047.025-.018.061-.01.091 0a2.434 2.434 0 0 0 1.638-.083c.027-.01.054-.017.075.002a.19.19 0 0 1 .028.032L21.95 38.05zM7.863 27.863L5.8 25.8l4.074-1.738a.084.084 0 0 1 .033-.007c.034 0 .054.034.072.065a2.91 2.91 0 0 0 .13.184l.013.016c.012.017.004.034-.008.05l-2.25 3.493zm-2.976-2.976l-2.61-2.61c-.444-.444-.766-.766-.99-1.043l7.936 1.646a.84.84 0 0 0 .03.005c.049.008.103.017.103.063 0 .05-.059.073-.109.092l-.023.01-4.337 1.837zM.831 19.892a2 2 0 0 1 .09-.495c.148-.466.55-.868 1.356-1.674l3.34-3.34a2175.525 2175.525 0 0 0 4.626 6.687c.027.036.057.076.026.106-.146.161-.292.337-.395.528a.16.16 0 0 1-.05.062c-.013.008-.027.005-.042.002H9.78L.831 19.892zm5.68-6.403l4.491-4.491c.422.185 1.958.834 3.332 1.414 1.04.44 1.988.84 2.286.97.03.012.057.024.07.054.008.018.004.041 0 .06a2.003 2.003 0 0 0 .523 1.828c.03.03 0 .073-.026.11l-.014.021-4.56 7.063c-.012.02-.023.037-.043.05-.024.015-.058.008-.086.001a2.274 2.274 0 0 0-.543-.074c-.164 0-.342.03-.522.063h-.001c-.02.003-.038.007-.054-.005a.21.21 0 0 1-.045-.051l-4.808-7.013zm5.398-5.398l5.814-5.814c.805-.805 1.208-1.208 1.674-1.355a2 2 0 0 1 1.206 0c.466.147.869.55 1.674 1.355l1.26 1.26-4.135 6.404a.155.155 0 0 1-.041.048c-.025.017-.06.01-.09 0a2.097 2.097 0 0 0-1.92.37c-.027.028-.067.012-.101-.003-.54-.235-4.74-2.01-5.341-2.265zm12.506-3.676l3.818 3.818-.92 5.698v.015a.135.135 0 0 1-.008.038c-.01.02-.03.024-.05.03a1.83 1.83 0 0 0-.548.273.154.154 0 0 0-.02.017c-.011.012-.022.023-.04.025a.114.114 0 0 1-.043-.007l-5.818-2.472-.011-.005c-.037-.015-.081-.033-.081-.071a2.198 2.198 0 0 0-.31-.915c-.028-.046-.059-.094-.035-.141l4.066-6.303zm-3.932 8.606l5.454 2.31c.03.014.063.027.076.058a.106.106 0 0 1 0 .057c-.016.08-.03.171-.03.263v.153c0 .038-.039.054-.075.069l-.011.004c-.864.369-12.13 5.173-12.147 5.173-.017 0-.035 0-.052-.017-.03-.03 0-.072.027-.11a.76.76 0 0 0 .014-.02l4.482-6.94.008-.012c.026-.042.056-.089.104-.089l.045.007c.102.014.192.027.283.027.68 0 1.31-.331 1.69-.897a.16.16 0 0 1 .034-.04c.027-.02.067-.01.098.004zm-6.246 9.185l12.28-5.237s.018 0 .035.017c.067.067.124.112.179.154l.027.017c.025.014.05.03.052.056 0 .01 0 .016-.002.025L25.756 23.7l-.004.026c-.007.05-.014.107-.061.107a1.729 1.729 0 0 0-1.373.847l-.005.008c-.014.023-.027.045-.05.057-.021.01-.048.006-.07.001l-9.793-2.02c-.01-.002-.152-.519-.163-.52z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new interface for site actions
|
||||||
|
interface SiteAction {
|
||||||
|
name: string;
|
||||||
|
icon: React.ComponentType<any>;
|
||||||
|
action: (siteId: string) => Promise<void>;
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
|
variant?: 'default' | 'destructive' | 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
export default function NetlifyConnection() {
|
export default function NetlifyConnection() {
|
||||||
const connection = useStore(netlifyConnection);
|
const connection = useStore(netlifyConnection);
|
||||||
const connecting = useStore(isConnecting);
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
const fetchingStats = useStore(isFetchingStats);
|
const [fetchingStats, setFetchingStats] = useState(false);
|
||||||
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
|
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||||
|
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||||
|
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||||
|
const [deploymentCount, setDeploymentCount] = useState(0);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState('');
|
||||||
|
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||||
|
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
|
||||||
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Add site actions
|
||||||
const fetchSites = async () => {
|
const siteActions: SiteAction[] = [
|
||||||
if (connection.user && connection.token) {
|
{
|
||||||
await fetchNetlifyStats(connection.token);
|
name: 'Clear Cache',
|
||||||
}
|
icon: ArrowPathIcon,
|
||||||
};
|
action: async (siteId: string) => {
|
||||||
fetchSites();
|
try {
|
||||||
}, [connection.user, connection.token]);
|
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${connection.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleConnect = async (event: React.FormEvent) => {
|
if (!response.ok) {
|
||||||
event.preventDefault();
|
throw new Error('Failed to clear cache');
|
||||||
isConnecting.set(true);
|
}
|
||||||
|
|
||||||
|
toast.success('Site cache cleared successfully');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to clear site cache: ${error}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete Site',
|
||||||
|
icon: TrashIcon,
|
||||||
|
action: async (siteId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${connection.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete site');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Site deleted successfully');
|
||||||
|
fetchNetlifyStats(connection.token);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to delete site: ${error}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requiresConfirmation: true,
|
||||||
|
variant: 'destructive',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add deploy management functions
|
||||||
|
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
setIsActionLoading(true);
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
action === 'publish'
|
||||||
|
? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
|
||||||
|
: `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${connection.token}`,
|
Authorization: `Bearer ${connection.token}`,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Invalid token or unauthorized');
|
throw new Error(`Failed to ${action} deploy`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Deploy ${action}ed successfully`);
|
||||||
|
fetchNetlifyStats(connection.token);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to ${action} deploy: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize connection with environment token if available
|
||||||
|
initializeNetlifyConnection();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if we have a connection with a token but no stats
|
||||||
|
if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
|
||||||
|
fetchNetlifyStats(connection.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state from connection
|
||||||
|
if (connection.stats) {
|
||||||
|
setSites(connection.stats.sites || []);
|
||||||
|
setDeploys(connection.stats.deploys || []);
|
||||||
|
setBuilds(connection.stats.builds || []);
|
||||||
|
setDeploymentCount(connection.stats.deploys?.length || 0);
|
||||||
|
setLastUpdated(connection.stats.lastDeployTime || '');
|
||||||
|
}
|
||||||
|
}, [connection]);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (!tokenInput) {
|
||||||
|
toast.error('Please enter a Netlify API token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokenInput}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = (await response.json()) as NetlifyUser;
|
const userData = (await response.json()) as NetlifyUser;
|
||||||
|
|
||||||
|
// Update the connection store
|
||||||
updateNetlifyConnection({
|
updateNetlifyConnection({
|
||||||
user: userData,
|
user: userData,
|
||||||
token: connection.token,
|
token: tokenInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchNetlifyStats(connection.token);
|
toast.success('Connected to Netlify successfully');
|
||||||
toast.success('Successfully connected to Netlify');
|
|
||||||
|
// Fetch stats after successful connection
|
||||||
|
fetchNetlifyStats(tokenInput);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth error:', error);
|
console.error('Error connecting to Netlify:', error);
|
||||||
logStore.logError('Failed to authenticate with Netlify', { error });
|
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
toast.error('Failed to connect to Netlify');
|
|
||||||
updateNetlifyConnection({ user: null, token: '' });
|
|
||||||
} finally {
|
} finally {
|
||||||
isConnecting.set(false);
|
setIsConnecting(false);
|
||||||
|
setTokenInput('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
|
// Clear from localStorage
|
||||||
|
localStorage.removeItem('netlify_connection');
|
||||||
|
|
||||||
|
// Remove cookies
|
||||||
|
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
|
||||||
|
// Update the store
|
||||||
updateNetlifyConnection({ user: null, token: '' });
|
updateNetlifyConnection({ user: null, token: '' });
|
||||||
toast.success('Disconnected from Netlify');
|
toast.success('Disconnected from Netlify');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchNetlifyStats = async (token: string) => {
|
||||||
|
setFetchingStats(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch sites
|
||||||
|
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sitesResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitesData = (await sitesResponse.json()) as NetlifySite[];
|
||||||
|
setSites(sitesData);
|
||||||
|
|
||||||
|
// Fetch recent deploys for the first site (if any)
|
||||||
|
let deploysData: NetlifyDeploy[] = [];
|
||||||
|
let buildsData: NetlifyBuild[] = [];
|
||||||
|
let lastDeployTime = '';
|
||||||
|
|
||||||
|
if (sitesData && sitesData.length > 0) {
|
||||||
|
const firstSite = sitesData[0];
|
||||||
|
|
||||||
|
// Fetch deploys
|
||||||
|
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deploysResponse.ok) {
|
||||||
|
deploysData = (await deploysResponse.json()) as NetlifyDeploy[];
|
||||||
|
setDeploys(deploysData);
|
||||||
|
setDeploymentCount(deploysData.length);
|
||||||
|
|
||||||
|
// Get the latest deploy time
|
||||||
|
if (deploysData.length > 0) {
|
||||||
|
lastDeployTime = deploysData[0].created_at;
|
||||||
|
setLastUpdated(lastDeployTime);
|
||||||
|
|
||||||
|
// Fetch builds for the site
|
||||||
|
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (buildsResponse.ok) {
|
||||||
|
buildsData = (await buildsResponse.json()) as NetlifyBuild[];
|
||||||
|
setBuilds(buildsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the stats in the store
|
||||||
|
updateNetlifyConnection({
|
||||||
|
stats: {
|
||||||
|
sites: sitesData,
|
||||||
|
deploys: deploysData,
|
||||||
|
builds: buildsData,
|
||||||
|
lastDeployTime,
|
||||||
|
totalSites: sitesData.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Netlify stats updated');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Netlify stats:', error);
|
||||||
|
toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setFetchingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStats = () => {
|
||||||
|
if (!connection.user || !connection.stats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
|
||||||
|
<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 dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
Netlify Stats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
||||||
|
isStatsOpen ? 'rotate-180' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="overflow-hidden">
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||||
|
<span>{connection.stats.totalSites} Sites</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<RocketLaunchIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||||
|
<span>{deploymentCount} Deployments</span>
|
||||||
|
</Badge>
|
||||||
|
{lastUpdated && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||||
|
<span>Updated {formatDistanceToNow(new Date(lastUpdated))} ago</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Your Sites
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchNetlifyStats(connection.token)}
|
||||||
|
disabled={fetchingStats}
|
||||||
|
className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon
|
||||||
|
className={classNames(
|
||||||
|
'h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent',
|
||||||
|
{ 'animate-spin': fetchingStats },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{fetchingStats ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sites.map((site, index) => (
|
||||||
|
<div
|
||||||
|
key={site.id}
|
||||||
|
className={classNames(
|
||||||
|
'bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border rounded-lg p-4 transition-all',
|
||||||
|
activeSiteIndex === index
|
||||||
|
? 'border-bolt-elements-item-contentAccent bg-bolt-elements-item-backgroundActive/10'
|
||||||
|
: 'border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSiteIndex(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CloudIcon className="h-5 w-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
{site.published_deploy?.state === 'ready' ? (
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
{site.published_deploy?.state || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={site.ssl_url || site.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="underline decoration-1 underline-offset-2">
|
||||||
|
{site.ssl_url || site.url}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSiteIndex === index && (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 pt-3 border-t border-bolt-elements-borderColor">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{siteActions.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.name}
|
||||||
|
variant={action.variant || 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (action.requiresConfirmation) {
|
||||||
|
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsActionLoading(true);
|
||||||
|
await action.action(site.id);
|
||||||
|
setIsActionLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<action.icon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
{action.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{site.published_deploy && (
|
||||||
|
<div className="mt-3 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
|
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{site.published_deploy.branch && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
|
Branch: {site.published_deploy.branch}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeSiteIndex !== -1 && deploys.length > 0 && (
|
||||||
|
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Recent Deployments
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deploys.map((deploy) => (
|
||||||
|
<div
|
||||||
|
key={deploy.id}
|
||||||
|
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
deploy.state === 'ready'
|
||||||
|
? 'default'
|
||||||
|
: deploy.state === 'error'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{deploy.state === 'ready' ? (
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||||
|
) : deploy.state === 'error' ? (
|
||||||
|
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
|
||||||
|
)}
|
||||||
|
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
{deploy.state}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
|
{formatDistanceToNow(new Date(deploy.created_at))} ago
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{deploy.branch && (
|
||||||
|
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-1">
|
||||||
|
<CodeBracketIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
|
Branch: {deploy.branch}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deploy.deploy_url && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<a
|
||||||
|
href={deploy.deploy_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
{deploy.state === 'ready' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<LockClosedIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Lock
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<LockOpenIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeSiteIndex !== -1 && builds.length > 0 && (
|
||||||
|
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
|
||||||
|
Recent Builds
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{builds.map((build) => (
|
||||||
|
<div
|
||||||
|
key={build.id}
|
||||||
|
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{build.done && !build.error ? (
|
||||||
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
|
) : build.error ? (
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<CodeBracketIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
|
{build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
|
{formatDistanceToNow(new Date(build.created_at))} ago
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{build.error && (
|
||||||
|
<div className="mt-2 text-xs text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive flex items-center gap-1">
|
||||||
|
<XCircleIcon className="h-3 w-3 text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive" />
|
||||||
|
Error: {build.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6 bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg">
|
||||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
<div className="p-6">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<div className="text-[#00AD9F]">
|
||||||
className="w-5 h-5"
|
<NetlifyLogo />
|
||||||
height="24"
|
</div>
|
||||||
width="24"
|
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Netlify Connection</h2>
|
||||||
crossOrigin="anonymous"
|
|
||||||
src="https://cdn.simpleicons.org/netlify"
|
|
||||||
/>
|
|
||||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!connection.user ? (
|
{!connection.user ? (
|
||||||
<div className="space-y-4">
|
<div className="mt-4">
|
||||||
<div>
|
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
|
||||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
API Token
|
||||||
<input
|
</label>
|
||||||
type="password"
|
<input
|
||||||
value={connection.token}
|
type="password"
|
||||||
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
value={tokenInput}
|
||||||
disabled={connecting}
|
onChange={(e) => setTokenInput(e.target.value)}
|
||||||
placeholder="Enter your Netlify personal access token"
|
placeholder="Enter your Netlify API token"
|
||||||
className={classNames(
|
|
||||||
'w-full px-3 py-2 rounded-lg text-sm',
|
|
||||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
|
||||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
|
||||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
|
||||||
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
|
|
||||||
'disabled:opacity-50',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
|
||||||
<a
|
|
||||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[#00AD9F] 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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connecting || !connection.token}
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
'w-full px-3 py-2 rounded-lg text-sm',
|
||||||
'bg-[#00AD9F] text-white',
|
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
|
||||||
'hover:bg-[#00968A]',
|
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{connecting ? (
|
<div className="mt-2 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
|
||||||
<>
|
<a
|
||||||
<div className="i-ph:spinner-gap animate-spin" />
|
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||||
Connecting...
|
target="_blank"
|
||||||
</>
|
rel="noopener noreferrer"
|
||||||
) : (
|
className="text-bolt-elements-link-text dark:text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:hover:text-bolt-elements-link-textHover flex items-center gap-1"
|
||||||
<>
|
>
|
||||||
<div className="i-ph:plug-charging w-4 h-4" />
|
<div className="i-ph:key w-4 h-4" />
|
||||||
Connect
|
Get your token
|
||||||
</>
|
<div className="i-ph:arrow-square-out w-3 h-3" />
|
||||||
)}
|
</a>
|
||||||
</button>
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnecting || !tokenInput}
|
||||||
|
variant="default"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CloudIcon className="w-4 h-4" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col w-full gap-4 mt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
|
||||||
<button
|
<div className="i-ph:sign-out w-4 h-4" />
|
||||||
onClick={handleDisconnect}
|
Disconnect
|
||||||
className={classNames(
|
</Button>
|
||||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
|
||||||
'bg-red-500 text-white',
|
<div className="flex items-center gap-2">
|
||||||
'hover:bg-red-600',
|
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||||
)}
|
<span className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
>
|
|
||||||
<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 Netlify
|
Connected to Netlify
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<img
|
<Button
|
||||||
src={connection.user.avatar_url}
|
variant="outline"
|
||||||
referrerPolicy="no-referrer"
|
onClick={() => window.open('https://app.netlify.com', '_blank', 'noopener,noreferrer')}
|
||||||
crossOrigin="anonymous"
|
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"
|
||||||
alt={connection.user.full_name}
|
|
||||||
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
|
||||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fetchingStats ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
|
||||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
|
||||||
Fetching Netlify sites...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
|
||||||
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<div className="i-ph:buildings w-4 h-4" />
|
<div className="i-ph:layout-dashboard w-4 h-4" />
|
||||||
Your Sites ({connection.stats?.totalSites || 0})
|
Dashboard
|
||||||
<div
|
</Button>
|
||||||
className={classNames(
|
<Button
|
||||||
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
onClick={() => fetchNetlifyStats(connection.token)}
|
||||||
isSitesExpanded ? 'rotate-180' : '',
|
disabled={fetchingStats}
|
||||||
)}
|
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"
|
||||||
</button>
|
>
|
||||||
{isSitesExpanded && connection.stats?.sites?.length ? (
|
{fetchingStats ? (
|
||||||
<div className="grid gap-3">
|
<>
|
||||||
{connection.stats.sites.map((site) => (
|
<div className="i-ph:spinner-gap w-4 h-4 animate-spin text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
|
||||||
<a
|
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
key={site.id}
|
Refreshing...
|
||||||
href={site.admin_url}
|
</span>
|
||||||
target="_blank"
|
</>
|
||||||
rel="noopener noreferrer"
|
) : (
|
||||||
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
|
<>
|
||||||
>
|
<ArrowPathIcon className="h-4 w-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||||
<div>
|
Refresh Stats
|
||||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
</span>
|
||||||
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
</>
|
||||||
{site.name}
|
)}
|
||||||
</h5>
|
</Button>
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
|
||||||
<a
|
|
||||||
href={site.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-[#00AD9F]"
|
|
||||||
>
|
|
||||||
{site.url}
|
|
||||||
</a>
|
|
||||||
{site.published_deploy && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:clock w-3 h-3" />
|
|
||||||
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{site.build_settings?.provider && (
|
|
||||||
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="i-ph:git-branch w-3 h-3" />
|
|
||||||
{site.build_settings.provider}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : isSitesExpanded ? (
|
|
||||||
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
|
||||||
<div className="i-ph:info w-4 h-4" />
|
|
||||||
No sites found in your Netlify account
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
{renderStats()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface ConnectionFormProps {
|
|||||||
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
||||||
// Check for saved token on mount
|
// Check for saved token on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
|
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
|
||||||
|
|
||||||
if (savedToken && !authState.tokenInfo?.token) {
|
if (savedToken && !authState.tokenInfo?.token) {
|
||||||
setAuthState((prev: GitHubAuthState) => ({
|
setAuthState((prev: GitHubAuthState) => ({
|
||||||
@@ -30,6 +30,9 @@ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }
|
|||||||
followers: 0,
|
followers: 0,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Ensure the token is also saved with the correct key for API requests
|
||||||
|
Cookies.set('githubToken', savedToken);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
|
import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
@@ -7,6 +7,7 @@ import { getLocalStorage } from '~/lib/persistence';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { formatSize } from '~/utils/formatSize';
|
import { formatSize } from '~/utils/formatSize';
|
||||||
import { Input } from '~/components/ui/Input';
|
import { Input } from '~/components/ui/Input';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
interface GitHubTreeResponse {
|
interface GitHubTreeResponse {
|
||||||
tree: Array<{
|
tree: Array<{
|
||||||
@@ -122,6 +123,184 @@ function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
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}`);
|
||||||
|
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-[#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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
||||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -133,13 +312,78 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
||||||
const [selectedBranch, setSelectedBranch] = useState('');
|
const [selectedBranch, setSelectedBranch] = useState('');
|
||||||
const [filters, setFilters] = useState<SearchFilters>({});
|
const [filters, setFilters] = useState<SearchFilters>({});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [stats, setStats] = useState<RepositoryStats | null>(null);
|
|
||||||
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
||||||
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
||||||
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
||||||
|
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||||
|
|
||||||
// Fetch user's repositories when dialog opens
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && activeTab === 'my-repos') {
|
if (isOpen && activeTab === 'my-repos') {
|
||||||
fetchUserRepos();
|
fetchUserRepos();
|
||||||
@@ -159,6 +403,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
||||||
headers: {
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
Authorization: `Bearer ${connection.token}`,
|
Authorization: `Bearer ${connection.token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -238,10 +483,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
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`, {
|
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -285,34 +535,97 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
|
|
||||||
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
||||||
try {
|
try {
|
||||||
const [owner, repo] = repoUrl
|
// 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$/, '')
|
.replace(/\.git$/, '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.slice(-2);
|
.slice(-2);
|
||||||
|
|
||||||
|
// Try to get token from local storage first
|
||||||
const connection = getLocalStorage('github_connection');
|
const connection = getLocalStorage('github_connection');
|
||||||
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
|
||||||
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
const repoObjData = (await repoObjResponse.json()) as any;
|
|
||||||
|
|
||||||
if (!repoObjData.default_branch) {
|
// If no connection in local storage, check environment variables
|
||||||
throw new Error('Failed to fetch repository branch');
|
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}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultBranch = repoObjData.default_branch;
|
// First, get the repository info to determine the default branch
|
||||||
|
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch repository tree
|
if (!repoInfoResponse.ok) {
|
||||||
const treeResponse = await fetch(
|
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`,
|
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
|
||||||
{
|
{
|
||||||
headers,
|
headers,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If the selected branch doesn't work, try common branch names
|
||||||
if (!treeResponse.ok) {
|
if (!treeResponse.ok) {
|
||||||
throw new Error('Failed to fetch repository structure');
|
// 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;
|
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
|
||||||
@@ -369,12 +682,27 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
hasDependencies,
|
hasDependencies,
|
||||||
};
|
};
|
||||||
|
|
||||||
setStats(stats);
|
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying repository:', error);
|
console.error('Error verifying repository:', error);
|
||||||
toast.error('Failed to verify repository');
|
|
||||||
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -408,7 +736,36 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
setShowStatsDialog(true);
|
setShowStatsDialog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preparing repository:', error);
|
console.error('Error preparing repository:', error);
|
||||||
toast.error('Failed to prepare repository. Please try again.');
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,182 +798,210 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root
|
<>
|
||||||
open={isOpen}
|
<Dialog.Root
|
||||||
onOpenChange={(open) => {
|
open={isOpen}
|
||||||
if (!open) {
|
onOpenChange={(open) => {
|
||||||
handleClose();
|
if (!open) {
|
||||||
}
|
handleClose();
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<Dialog.Portal>
|
>
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
<Dialog.Portal>
|
||||||
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
||||||
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
||||||
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
||||||
Import GitHub Repository
|
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
</Dialog.Title>
|
Import GitHub Repository
|
||||||
<Dialog.Close
|
</Dialog.Title>
|
||||||
onClick={handleClose}
|
<Dialog.Close
|
||||||
className={classNames(
|
onClick={handleClose}
|
||||||
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
className={classNames(
|
||||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
||||||
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
||||||
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-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>
|
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||||
</Dialog.Close>
|
<span className="sr-only">Close dialog</span>
|
||||||
</div>
|
</Dialog.Close>
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
|
||||||
<span className="i-ph:book-bookmark" />
|
|
||||||
My Repos
|
|
||||||
</TabButton>
|
|
||||||
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
|
||||||
<span className="i-ph:magnifying-glass" />
|
|
||||||
Search
|
|
||||||
</TabButton>
|
|
||||||
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
|
||||||
<span className="i-ph:link" />
|
|
||||||
URL
|
|
||||||
</TabButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'url' ? (
|
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
||||||
<div className="space-y-4">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<span className="i-ph:info text-blue-500" />
|
||||||
placeholder="Enter repository URL"
|
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
value={customUrl}
|
Need to access private repositories?
|
||||||
onChange={(e) => setCustomUrl(e.target.value)}
|
</span>
|
||||||
className={classNames('w-full', {
|
|
||||||
'border-red-500': false,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={!customUrl}
|
|
||||||
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
|
|
||||||
>
|
|
||||||
Import Repository
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button
|
||||||
<>
|
onClick={() => setShowAuthDialog(true)}
|
||||||
{activeTab === 'search' && (
|
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"
|
||||||
<div className="space-y-4 mb-4">
|
>
|
||||||
<div className="flex gap-2">
|
<span className="i-ph:key" />
|
||||||
<input
|
Connect GitHub Account
|
||||||
type="text"
|
</button>
|
||||||
placeholder="Search repositories..."
|
</div>
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
<div className="p-4">
|
||||||
setSearchQuery(e.target.value);
|
<div className="flex gap-2 mb-4">
|
||||||
handleSearch(e.target.value);
|
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
||||||
}}
|
<span className="i-ph:book-bookmark" />
|
||||||
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
My Repos
|
||||||
/>
|
</TabButton>
|
||||||
<button
|
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
||||||
onClick={() => setFilters({})}
|
<span className="i-ph:magnifying-glass" />
|
||||||
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
Search
|
||||||
>
|
</TabButton>
|
||||||
<span className="i-ph:funnel-simple" />
|
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
||||||
</button>
|
<span className="i-ph:link" />
|
||||||
</div>
|
URL
|
||||||
<div className="grid grid-cols-2 gap-2">
|
</TabButton>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
|
||||||
placeholder="Filter by language..."
|
{activeTab === 'url' ? (
|
||||||
value={filters.language || ''}
|
<div className="space-y-4">
|
||||||
onChange={(e) => {
|
<Input
|
||||||
setFilters({ ...filters, language: e.target.value });
|
type="text"
|
||||||
handleSearch(searchQuery);
|
placeholder="Enter GitHub repository URL"
|
||||||
}}
|
value={customUrl}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
/>
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!customUrl}
|
||||||
|
className={classNames(
|
||||||
|
'w-full h-10 px-4 py-2 rounded-lg text-white transition-all duration-200 flex items-center gap-2 justify-center',
|
||||||
|
customUrl
|
||||||
|
? 'bg-purple-500 hover:bg-purple-600'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Import Repository
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'search' && (
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
handleSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({})}
|
||||||
|
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||||
|
>
|
||||||
|
<span className="i-ph:funnel-simple" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by language..."
|
||||||
|
value={filters.language || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilters({ ...filters, language: e.target.value });
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min stars..."
|
||||||
|
value={filters.stars || ''}
|
||||||
|
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Min stars..."
|
placeholder="Min forks..."
|
||||||
value={filters.stars || ''}
|
value={filters.forks || ''}
|
||||||
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Min forks..."
|
|
||||||
value={filters.forks || ''}
|
|
||||||
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
|
||||||
{selectedRepository ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedRepository(null)}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
|
||||||
>
|
|
||||||
<span className="i-ph:arrow-left w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
|
||||||
<select
|
|
||||||
value={selectedBranch}
|
|
||||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
|
||||||
>
|
|
||||||
{branches.map((branch) => (
|
|
||||||
<option
|
|
||||||
key={branch.name}
|
|
||||||
value={branch.name}
|
|
||||||
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
|
||||||
>
|
|
||||||
{branch.name} {branch.default ? '(default)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
|
||||||
>
|
|
||||||
Import Selected Branch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<RepositoryList
|
|
||||||
repos={activeTab === 'my-repos' ? repositories : searchResults}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onSelect={handleRepoSelect}
|
|
||||||
activeTab={activeTab}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</>
|
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
)}
|
{selectedRepository ? (
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</Dialog.Content>
|
<div className="flex items-center gap-2">
|
||||||
</Dialog.Portal>
|
<button
|
||||||
{currentStats && (
|
onClick={() => setSelectedRepository(null)}
|
||||||
<StatsDialog
|
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
||||||
isOpen={showStatsDialog}
|
>
|
||||||
onClose={handleStatsConfirm}
|
<span className="i-ph:arrow-left w-4 h-4" />
|
||||||
onConfirm={handleStatsConfirm}
|
</button>
|
||||||
stats={currentStats}
|
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
||||||
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
|
</div>
|
||||||
/>
|
<div className="space-y-2">
|
||||||
)}
|
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
||||||
</Dialog.Root>
|
<select
|
||||||
|
value={selectedBranch}
|
||||||
|
onChange={(e) => setSelectedBranch(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||||
|
>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<option
|
||||||
|
key={branch.name}
|
||||||
|
value={branch.name}
|
||||||
|
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||||
|
>
|
||||||
|
{branch.name} {branch.default ? '(default)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
||||||
|
>
|
||||||
|
Import Selected Branch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RepositoryList
|
||||||
|
repos={activeTab === 'my-repos' ? repositories : searchResults}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSelect={handleRepoSelect}
|
||||||
|
activeTab={activeTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +1055,7 @@ function RepositoryList({
|
|||||||
|
|
||||||
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
|
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
||||||
|
|||||||
@@ -159,10 +159,10 @@ ${escapeBoltTags(file.content)}
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
'gap-2 bg-bolt-elements-background-depth-1',
|
||||||
'text-bolt-elements-textPrimary dark:text-white',
|
'text-bolt-elements-textPrimary',
|
||||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
'hover:bg-bolt-elements-background-depth-2',
|
||||||
'border-[#E5E5E5] dark:border-[#333333]',
|
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
'gap-2 bg-bolt-elements-background-depth-1',
|
||||||
'text-bolt-elements-textPrimary dark:text-white',
|
'text-bolt-elements-textPrimary',
|
||||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
'hover:bg-bolt-elements-background-depth-2',
|
||||||
'border-[#E5E5E5] dark:border-[#333333]',
|
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
'gap-2 bg-bolt-elements-background-depth-1',
|
||||||
'text-bolt-elements-textPrimary dark:text-white',
|
'text-bolt-elements-textPrimary',
|
||||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
'hover:bg-bolt-elements-background-depth-2',
|
||||||
'border-[#E5E5E5] dark:border-[#333333]',
|
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
)}
|
)}
|
||||||
@@ -81,10 +81,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
|||||||
<ImportFolderButton
|
<ImportFolderButton
|
||||||
importChat={importChat}
|
importChat={importChat}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
'gap-2 bg-bolt-elements-background-depth-1',
|
||||||
'text-bolt-elements-textPrimary dark:text-white',
|
'text-bolt-elements-textPrimary',
|
||||||
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
'hover:bg-bolt-elements-background-depth-2',
|
||||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
'border border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||||
'transition-all duration-200 ease-in-out rounded-lg',
|
'transition-all duration-200 ease-in-out rounded-lg',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
default: 'bg-bolt-elements-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
||||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||||
outline:
|
outline:
|
||||||
'border border-input bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary',
|
'border border-bolt-elements-borderColor bg-transparent hover:bg-bolt-elements-background-depth-2 hover:text-bolt-elements-textPrimary text-bolt-elements-textPrimary dark:border-bolt-elements-borderColorActive',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2',
|
||||||
ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',
|
ghost: 'hover:bg-bolt-elements-background-depth-1 hover:text-bolt-elements-textPrimary',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
|
|||||||
icon="i-ph:gear"
|
icon="i-ph:gear"
|
||||||
size="xl"
|
size="xl"
|
||||||
title="Settings"
|
title="Settings"
|
||||||
|
data-testid="settings-button"
|
||||||
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
|
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export function useGit() {
|
|||||||
|
|
||||||
fileData.current = {};
|
fileData.current = {};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Skip Git initialization for now - let isomorphic-git handle it
|
||||||
|
* This avoids potential issues with our manual initialization
|
||||||
|
*/
|
||||||
|
|
||||||
const headers: {
|
const headers: {
|
||||||
[x: string]: string;
|
[x: string]: string;
|
||||||
} = {
|
} = {
|
||||||
@@ -72,18 +77,23 @@ export function useGit() {
|
|||||||
singleBranch: true,
|
singleBranch: true,
|
||||||
corsProxy: '/api/git-proxy',
|
corsProxy: '/api/git-proxy',
|
||||||
headers,
|
headers,
|
||||||
|
onProgress: (event) => {
|
||||||
|
console.log('Git clone progress:', event);
|
||||||
|
},
|
||||||
onAuth: (url) => {
|
onAuth: (url) => {
|
||||||
let auth = lookupSavedPassword(url);
|
let auth = lookupSavedPassword(url);
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
|
console.log('Using saved authentication for', url);
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Repository requires authentication:', url);
|
||||||
|
|
||||||
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
|
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
|
||||||
auth = {
|
auth = {
|
||||||
username: prompt('Enter username'),
|
username: prompt('Enter username') || '',
|
||||||
password: prompt('Enter password'),
|
password: prompt('Enter password') || '',
|
||||||
};
|
};
|
||||||
return auth;
|
return auth;
|
||||||
} else {
|
} else {
|
||||||
@@ -91,10 +101,12 @@ export function useGit() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAuthFailure: (url, _auth) => {
|
onAuthFailure: (url, _auth) => {
|
||||||
|
console.error(`Authentication failed for ${url}`);
|
||||||
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
|
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
|
||||||
throw `Error Authenticating with ${url.split('/')[2]}`;
|
throw `Error Authenticating with ${url.split('/')[2]}`;
|
||||||
},
|
},
|
||||||
onAuthSuccess: (url, auth) => {
|
onAuthSuccess: (url, auth) => {
|
||||||
|
console.log(`Authentication successful for ${url}`);
|
||||||
saveGitAuth(url, auth);
|
saveGitAuth(url, auth);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -136,18 +148,26 @@ const getFs = (
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
writeFile: async (path: string, data: any, options: any) => {
|
writeFile: async (path: string, data: any, options: any = {}) => {
|
||||||
const encoding = options.encoding;
|
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||||
|
|
||||||
if (record.current) {
|
if (record.current) {
|
||||||
record.current[relativePath] = { data, encoding };
|
record.current[relativePath] = { data, encoding: options?.encoding };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
|
// Handle encoding properly based on data type
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
// For binary data, don't pass encoding
|
||||||
|
const result = await webcontainer.fs.writeFile(relativePath, data);
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
// For text data, use the encoding if provided
|
||||||
|
const encoding = options?.encoding || 'utf8';
|
||||||
|
const result = await webcontainer.fs.writeFile(relativePath, data, encoding);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -208,33 +228,80 @@ const getFs = (
|
|||||||
stat: async (path: string) => {
|
stat: async (path: string) => {
|
||||||
try {
|
try {
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||||
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
|
const dirPath = pathUtils.dirname(relativePath);
|
||||||
const name = pathUtils.basename(relativePath);
|
const fileName = pathUtils.basename(relativePath);
|
||||||
const fileInfo = resp.find((x) => x.name == name);
|
|
||||||
|
// Special handling for .git/index file
|
||||||
|
if (relativePath === '.git/index') {
|
||||||
|
return {
|
||||||
|
isFile: () => true,
|
||||||
|
isDirectory: () => false,
|
||||||
|
isSymbolicLink: () => false,
|
||||||
|
size: 12, // Size of our empty index
|
||||||
|
mode: 0o100644, // Regular file
|
||||||
|
mtimeMs: Date.now(),
|
||||||
|
ctimeMs: Date.now(),
|
||||||
|
birthtimeMs: Date.now(),
|
||||||
|
atimeMs: Date.now(),
|
||||||
|
uid: 1000,
|
||||||
|
gid: 1000,
|
||||||
|
dev: 1,
|
||||||
|
ino: 1,
|
||||||
|
nlink: 1,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 4096,
|
||||||
|
blocks: 1,
|
||||||
|
mtime: new Date(),
|
||||||
|
ctime: new Date(),
|
||||||
|
birthtime: new Date(),
|
||||||
|
atime: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await webcontainer.fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
const fileInfo = resp.find((x) => x.name === fileName);
|
||||||
|
|
||||||
if (!fileInfo) {
|
if (!fileInfo) {
|
||||||
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
|
||||||
|
err.code = 'ENOENT';
|
||||||
|
err.errno = -2;
|
||||||
|
err.syscall = 'stat';
|
||||||
|
err.path = path;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFile: () => fileInfo.isFile(),
|
isFile: () => fileInfo.isFile(),
|
||||||
isDirectory: () => fileInfo.isDirectory(),
|
isDirectory: () => fileInfo.isDirectory(),
|
||||||
isSymbolicLink: () => false,
|
isSymbolicLink: () => false,
|
||||||
size: 1,
|
size: fileInfo.isDirectory() ? 4096 : 1,
|
||||||
mode: 0o666, // Default permissions
|
mode: fileInfo.isDirectory() ? 0o040755 : 0o100644, // Directory or regular file
|
||||||
mtimeMs: Date.now(),
|
mtimeMs: Date.now(),
|
||||||
|
ctimeMs: Date.now(),
|
||||||
|
birthtimeMs: Date.now(),
|
||||||
|
atimeMs: Date.now(),
|
||||||
uid: 1000,
|
uid: 1000,
|
||||||
gid: 1000,
|
gid: 1000,
|
||||||
|
dev: 1,
|
||||||
|
ino: 1,
|
||||||
|
nlink: 1,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 4096,
|
||||||
|
blocks: 8,
|
||||||
|
mtime: new Date(),
|
||||||
|
ctime: new Date(),
|
||||||
|
birthtime: new Date(),
|
||||||
|
atime: new Date(),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error?.message);
|
if (!error.code) {
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
error.errno = -2;
|
||||||
|
error.syscall = 'stat';
|
||||||
|
error.path = path;
|
||||||
|
}
|
||||||
|
|
||||||
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
|
throw error;
|
||||||
err.code = 'ENOENT';
|
|
||||||
err.errno = -2;
|
|
||||||
err.syscall = 'stat';
|
|
||||||
err.path = path;
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lstat: async (path: string) => {
|
lstat: async (path: string) => {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { atom } from 'nanostores';
|
import { atom } from 'nanostores';
|
||||||
import type { NetlifyConnection } from '~/types/netlify';
|
import type { NetlifyConnection, NetlifyUser } from '~/types/netlify';
|
||||||
import { logStore } from './logs';
|
import { logStore } from './logs';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
// Initialize with stored connection or defaults
|
// Initialize with stored connection or environment variable
|
||||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
||||||
|
const envToken = import.meta.env.VITE_NETLIFY_ACCESS_TOKEN;
|
||||||
|
|
||||||
|
// If we have an environment token but no stored connection, initialize with the env token
|
||||||
const initialConnection: NetlifyConnection = storedConnection
|
const initialConnection: NetlifyConnection = storedConnection
|
||||||
? JSON.parse(storedConnection)
|
? JSON.parse(storedConnection)
|
||||||
: {
|
: {
|
||||||
user: null,
|
user: null,
|
||||||
token: '',
|
token: envToken || '',
|
||||||
stats: undefined,
|
stats: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +20,52 @@ export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
|
|||||||
export const isConnecting = atom<boolean>(false);
|
export const isConnecting = atom<boolean>(false);
|
||||||
export const isFetchingStats = atom<boolean>(false);
|
export const isFetchingStats = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Function to initialize Netlify connection with environment token
|
||||||
|
export async function initializeNetlifyConnection() {
|
||||||
|
const currentState = netlifyConnection.get();
|
||||||
|
|
||||||
|
// If we already have a connection, don't override it
|
||||||
|
if (currentState.user || !envToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isConnecting.set(true);
|
||||||
|
|
||||||
|
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${envToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to connect to Netlify: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
|
||||||
|
// Update the connection state
|
||||||
|
const connectionData: Partial<NetlifyConnection> = {
|
||||||
|
user: userData as NetlifyUser,
|
||||||
|
token: envToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in localStorage for persistence
|
||||||
|
localStorage.setItem('netlify_connection', JSON.stringify(connectionData));
|
||||||
|
|
||||||
|
// Update the store
|
||||||
|
updateNetlifyConnection(connectionData);
|
||||||
|
|
||||||
|
// Fetch initial stats
|
||||||
|
await fetchNetlifyStats(envToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing Netlify connection:', error);
|
||||||
|
logStore.logError('Failed to initialize Netlify connection', { error });
|
||||||
|
} finally {
|
||||||
|
isConnecting.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
|
||||||
const currentState = netlifyConnection.get();
|
const currentState = netlifyConnection.get();
|
||||||
const newState = { ...currentState, ...updates };
|
const newState = { ...currentState, ...updates };
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async function handleProxyRequest(request: Request, path: string | undefined) {
|
|||||||
// Add body for non-GET/HEAD requests
|
// Add body for non-GET/HEAD requests
|
||||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||||
fetchOptions.body = request.body;
|
fetchOptions.body = request.body;
|
||||||
|
fetchOptions.duplex = 'half';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Note: duplex property is removed to ensure TypeScript compatibility
|
* Note: duplex property is removed to ensure TypeScript compatibility
|
||||||
|
|||||||
142
app/routes/api.system.diagnostics.ts
Normal file
142
app/routes/api.system.diagnostics.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic API for troubleshooting connection issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AppContext {
|
||||||
|
env?: {
|
||||||
|
GITHUB_ACCESS_TOKEN?: string;
|
||||||
|
NETLIFY_TOKEN?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => {
|
||||||
|
// Get environment variables
|
||||||
|
const envVars = {
|
||||||
|
hasGithubToken: Boolean(process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN),
|
||||||
|
hasNetlifyToken: Boolean(process.env.NETLIFY_TOKEN || context.env?.NETLIFY_TOKEN),
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cookies
|
||||||
|
const cookieHeader = request.headers.get('Cookie') || '';
|
||||||
|
const cookies = cookieHeader.split(';').reduce(
|
||||||
|
(acc, cookie) => {
|
||||||
|
const [key, value] = cookie.trim().split('=');
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasGithubTokenCookie = Boolean(cookies.githubToken);
|
||||||
|
const hasGithubUsernameCookie = Boolean(cookies.githubUsername);
|
||||||
|
const hasNetlifyCookie = Boolean(cookies.netlifyToken);
|
||||||
|
|
||||||
|
// Get local storage status (this can only be checked client-side)
|
||||||
|
const localStorageStatus = {
|
||||||
|
explanation: 'Local storage can only be checked on the client side. Use browser devtools to check.',
|
||||||
|
githubKeysToCheck: ['github_connection'],
|
||||||
|
netlifyKeysToCheck: ['netlify_connection'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if CORS might be an issue
|
||||||
|
const corsStatus = {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if API endpoints are reachable
|
||||||
|
const apiEndpoints = {
|
||||||
|
githubUser: '/api/system/git-info?action=getUser',
|
||||||
|
githubRepos: '/api/system/git-info?action=getRepos',
|
||||||
|
githubOrgs: '/api/system/git-info?action=getOrgs',
|
||||||
|
githubActivity: '/api/system/git-info?action=getActivity',
|
||||||
|
gitInfo: '/api/system/git-info',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test GitHub API connectivity
|
||||||
|
let githubApiStatus;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const githubResponse = await fetch('https://api.github.com/zen', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
githubApiStatus = {
|
||||||
|
isReachable: githubResponse.ok,
|
||||||
|
status: githubResponse.status,
|
||||||
|
statusText: githubResponse.statusText,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
githubApiStatus = {
|
||||||
|
isReachable: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Netlify API connectivity
|
||||||
|
let netlifyApiStatus;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const netlifyResponse = await fetch('https://api.netlify.com/api/v1/', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
netlifyApiStatus = {
|
||||||
|
isReachable: netlifyResponse.ok,
|
||||||
|
status: netlifyResponse.status,
|
||||||
|
statusText: netlifyResponse.statusText,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
netlifyApiStatus = {
|
||||||
|
isReachable: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide technical details about the environment
|
||||||
|
const technicalDetails = {
|
||||||
|
serverTimestamp: new Date().toISOString(),
|
||||||
|
userAgent: request.headers.get('User-Agent'),
|
||||||
|
referrer: request.headers.get('Referer'),
|
||||||
|
host: request.headers.get('Host'),
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return diagnostics
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
environment: envVars,
|
||||||
|
cookies: {
|
||||||
|
hasGithubTokenCookie,
|
||||||
|
hasGithubUsernameCookie,
|
||||||
|
hasNetlifyCookie,
|
||||||
|
},
|
||||||
|
localStorage: localStorageStatus,
|
||||||
|
apiEndpoints,
|
||||||
|
externalApis: {
|
||||||
|
github: githubApiStatus,
|
||||||
|
netlify: netlifyApiStatus,
|
||||||
|
},
|
||||||
|
corsStatus,
|
||||||
|
technicalDetails,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: corsStatus.headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { json, type LoaderFunction } from '@remix-run/cloudflare';
|
import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
|
|
||||||
interface GitInfo {
|
interface GitInfo {
|
||||||
local: {
|
local: {
|
||||||
@@ -20,6 +20,31 @@ interface GitInfo {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
isForked?: boolean;
|
isForked?: boolean;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define context type
|
||||||
|
interface AppContext {
|
||||||
|
env?: {
|
||||||
|
GITHUB_ACCESS_TOKEN?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubRepo {
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
html_url: string;
|
||||||
|
description: string;
|
||||||
|
stargazers_count: number;
|
||||||
|
forks_count: number;
|
||||||
|
language: string | null;
|
||||||
|
languages_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubGist {
|
||||||
|
id: string;
|
||||||
|
html_url: string;
|
||||||
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These values will be replaced at build time
|
// These values will be replaced at build time
|
||||||
@@ -31,7 +56,260 @@ declare const __GIT_EMAIL: string;
|
|||||||
declare const __GIT_REMOTE_URL: string;
|
declare const __GIT_REMOTE_URL: string;
|
||||||
declare const __GIT_REPO_NAME: string;
|
declare const __GIT_REPO_NAME: string;
|
||||||
|
|
||||||
export const loader: LoaderFunction = async () => {
|
/*
|
||||||
|
* Remove unused variable to fix linter error
|
||||||
|
* declare const __GIT_REPO_URL: string;
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => {
|
||||||
|
console.log('Git info API called with URL:', request.url);
|
||||||
|
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get('action');
|
||||||
|
|
||||||
|
console.log('Git info action:', action);
|
||||||
|
|
||||||
|
if (action === 'getUser' || action === 'getRepos' || action === 'getOrgs' || action === 'getActivity') {
|
||||||
|
// Use server-side token instead of client-side token
|
||||||
|
const serverGithubToken = process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN;
|
||||||
|
const cookieToken = request.headers
|
||||||
|
.get('Cookie')
|
||||||
|
?.split(';')
|
||||||
|
.find((cookie) => cookie.trim().startsWith('githubToken='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
// Also check for token in Authorization header
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||||
|
|
||||||
|
const token = serverGithubToken || headerToken || cookieToken;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Using GitHub token from:',
|
||||||
|
serverGithubToken ? 'server env' : headerToken ? 'auth header' : cookieToken ? 'cookie' : 'none',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('No GitHub token available');
|
||||||
|
return json(
|
||||||
|
{ error: 'No GitHub token available' },
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'getUser') {
|
||||||
|
const response = await fetch('https://api.github.com/user', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('GitHub user API error:', response.status);
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ user: userData },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'getRepos') {
|
||||||
|
const reposResponse = await fetch('https://api.github.com/user/repos?per_page=100&sort=updated', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reposResponse.ok) {
|
||||||
|
console.error('GitHub repos API error:', reposResponse.status);
|
||||||
|
throw new Error(`GitHub API error: ${reposResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = (await reposResponse.json()) as GitHubRepo[];
|
||||||
|
|
||||||
|
// Get user's gists
|
||||||
|
const gistsResponse = await fetch('https://api.github.com/gists', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gists = gistsResponse.ok ? ((await gistsResponse.json()) as GitHubGist[]) : [];
|
||||||
|
|
||||||
|
// Calculate language statistics
|
||||||
|
const languageStats: Record<string, number> = {};
|
||||||
|
let totalStars = 0;
|
||||||
|
let totalForks = 0;
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
totalStars += repo.stargazers_count || 0;
|
||||||
|
totalForks += repo.forks_count || 0;
|
||||||
|
|
||||||
|
if (repo.language && repo.language !== 'null') {
|
||||||
|
languageStats[repo.language] = (languageStats[repo.language] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Optionally fetch languages for each repo for more accurate stats
|
||||||
|
* This is commented out to avoid rate limiting
|
||||||
|
*
|
||||||
|
* if (repo.languages_url) {
|
||||||
|
* try {
|
||||||
|
* const langResponse = await fetch(repo.languages_url, {
|
||||||
|
* headers: {
|
||||||
|
* Accept: 'application/vnd.github.v3+json',
|
||||||
|
* Authorization: `Bearer ${token}`,
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* if (langResponse.ok) {
|
||||||
|
* const languages = await langResponse.json();
|
||||||
|
* Object.keys(languages).forEach(lang => {
|
||||||
|
* languageStats[lang] = (languageStats[lang] || 0) + languages[lang];
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error(`Error fetching languages for ${repo.name}:`, error);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
repos,
|
||||||
|
stats: {
|
||||||
|
totalStars,
|
||||||
|
totalForks,
|
||||||
|
languages: languageStats,
|
||||||
|
totalGists: gists.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'getOrgs') {
|
||||||
|
const response = await fetch('https://api.github.com/user/orgs', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('GitHub orgs API error:', response.status);
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgs = await response.json();
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ organizations: orgs },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'getActivity') {
|
||||||
|
const username = request.headers
|
||||||
|
.get('Cookie')
|
||||||
|
?.split(';')
|
||||||
|
.find((cookie) => cookie.trim().startsWith('githubUsername='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
console.error('GitHub username not found in cookies');
|
||||||
|
return json(
|
||||||
|
{ error: 'GitHub username not found in cookies' },
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://api.github.com/users/${username}/events?per_page=30`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('GitHub activity API error:', response.status);
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await response.json();
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ recentActivity: events },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GitHub API error:', error);
|
||||||
|
return json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const gitInfo: GitInfo = {
|
const gitInfo: GitInfo = {
|
||||||
local: {
|
local: {
|
||||||
commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
|
commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development',
|
||||||
@@ -42,7 +320,13 @@ export const loader: LoaderFunction = async () => {
|
|||||||
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
|
remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local',
|
||||||
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
|
repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy',
|
||||||
},
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(gitInfo);
|
return json(gitInfo, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,64 @@ export interface NetlifySite {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
ssl_url?: string;
|
||||||
admin_url: string;
|
admin_url: string;
|
||||||
|
screenshot_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
state?: string;
|
||||||
|
branch?: string;
|
||||||
|
custom_domain?: string;
|
||||||
build_settings: {
|
build_settings: {
|
||||||
provider: string;
|
provider: string;
|
||||||
repo_url: string;
|
repo_url: string;
|
||||||
|
repo_branch?: string;
|
||||||
cmd: string;
|
cmd: string;
|
||||||
};
|
};
|
||||||
published_deploy: {
|
published_deploy: {
|
||||||
|
id?: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
deploy_time: number;
|
deploy_time: number;
|
||||||
|
state?: string;
|
||||||
|
branch?: string;
|
||||||
|
commit_ref?: string;
|
||||||
|
commit_url?: string;
|
||||||
|
error_message?: string;
|
||||||
|
framework?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NetlifyDeploy {
|
||||||
|
id: string;
|
||||||
|
site_id: string;
|
||||||
|
state: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
ssl_url?: string;
|
||||||
|
admin_url?: string;
|
||||||
|
deploy_url: string;
|
||||||
|
deploy_ssl_url?: string;
|
||||||
|
screenshot_url?: string;
|
||||||
|
branch: string;
|
||||||
|
commit_ref?: string;
|
||||||
|
commit_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
published_at?: string;
|
||||||
|
title?: string;
|
||||||
|
framework?: string;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetlifyBuild {
|
||||||
|
id: string;
|
||||||
|
deploy_id: string;
|
||||||
|
sha?: string;
|
||||||
|
done: boolean;
|
||||||
|
error?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetlifyUser {
|
export interface NetlifyUser {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -25,6 +71,9 @@ export interface NetlifyUser {
|
|||||||
export interface NetlifyStats {
|
export interface NetlifyStats {
|
||||||
sites: NetlifySite[];
|
sites: NetlifySite[];
|
||||||
totalSites: number;
|
totalSites: number;
|
||||||
|
deploys?: NetlifyDeploy[];
|
||||||
|
builds?: NetlifyBuild[];
|
||||||
|
lastDeployTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetlifyConnection {
|
export interface NetlifyConnection {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const getGitHubRepoContent = async (
|
|||||||
|
|
||||||
// Add your GitHub token if needed
|
// Add your GitHub token if needed
|
||||||
if (token) {
|
if (token) {
|
||||||
headers.Authorization = 'token ' + token;
|
headers.Authorization = 'Bearer ' + token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch contents of the path
|
// Fetch contents of the path
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.35.0",
|
"@codemirror/view": "^6.35.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
"@iconify-json/svg-spinners": "^1.2.1",
|
"@iconify-json/svg-spinners": "^1.2.1",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/diff": "^5.2.3",
|
"@types/diff": "^5.2.3",
|
||||||
"@types/dom-speech-recognition": "^0.0.4",
|
"@types/dom-speech-recognition": "^0.0.4",
|
||||||
"@types/electron": "^1.6.10",
|
"@types/electron": "^1.6.12",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/path-browserify": "^1.0.3",
|
"@types/path-browserify": "^1.0.3",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -83,6 +83,9 @@ importers:
|
|||||||
'@headlessui/react':
|
'@headlessui/react':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@heroicons/react':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0(react@18.3.1)
|
||||||
'@iconify-json/svg-spinners':
|
'@iconify-json/svg-spinners':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
@@ -364,7 +367,7 @@ importers:
|
|||||||
specifier: ^0.0.4
|
specifier: ^0.0.4
|
||||||
version: 0.0.4
|
version: 0.0.4
|
||||||
'@types/electron':
|
'@types/electron':
|
||||||
specifier: ^1.6.10
|
specifier: ^1.6.12
|
||||||
version: 1.6.12
|
version: 1.6.12
|
||||||
'@types/file-saver':
|
'@types/file-saver':
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
@@ -1842,6 +1845,11 @@ packages:
|
|||||||
react: ^18 || ^19 || ^19.0.0-rc
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
|
'@heroicons/react@2.2.0':
|
||||||
|
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16 || ^19.0.0-rc'
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -9546,6 +9554,10 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@heroicons/react@2.2.0(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
|
|||||||
Reference in New Issue
Block a user