mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
feat: add one-click netlify deployment
This commit is contained in:
@@ -3,6 +3,8 @@ import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { GithubConnection } from './GithubConnection';
|
||||
import { NetlifyConnection } from './NetlifyConnection';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
@@ -257,347 +259,9 @@ export default function ConnectionsTab() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* GitHub Connection */}
|
||||
<motion.div
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) =>
|
||||
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
||||
}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
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',
|
||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-10 h-5" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!connection.user ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-purple-500 text-white',
|
||||
'hover:bg-purple-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug-x w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
{connection.user && (
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && (
|
||||
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingStats ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching GitHub stats...
|
||||
</div>
|
||||
) : (
|
||||
connection.stats && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user.public_repos}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalStars}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalForks}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h3>
|
||||
{connection.user.bio && (
|
||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:users w-4 h-4" />
|
||||
{connection.user.followers} followers
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-4 h-4" />
|
||||
{connection.stats.totalStars} stars
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-4 h-4" />
|
||||
{connection.stats.totalForks} forks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations Section */}
|
||||
{connection.stats.organizations.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{connection.stats.organizations.map((org) => (
|
||||
<a
|
||||
key={org.login}
|
||||
href={org.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
||||
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.recentActivity.map((event) => (
|
||||
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
||||
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
||||
<span>on</span>
|
||||
<a
|
||||
href={`https://github.com/${event.repo.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline"
|
||||
>
|
||||
{event.repo.name}
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
||||
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{new Date(connection.user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.totalGists}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.organizations.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{Object.keys(connection.stats.languages).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing repositories section */}
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
{repo.name}
|
||||
</h5>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-branch w-3 h-3" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<GithubConnection/>
|
||||
{/* Netlify Connection */}
|
||||
<NetlifyConnection/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
556
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
556
app/components/@settings/tabs/connections/GithubConnection.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
totalStars: number;
|
||||
totalForks: number;
|
||||
organizations: GitHubOrganization[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
}
|
||||
|
||||
export function GithubConnection() {
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
setConnection(parsed);
|
||||
|
||||
if (parsed.user && parsed.token) {
|
||||
fetchGitHubStats(parsed.token);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
try {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
const reposResponse = await fetch(
|
||||
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error('Failed to fetch repositories');
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
||||
|
||||
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgsResponse.ok) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
}
|
||||
|
||||
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
||||
|
||||
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error('Failed to fetch events');
|
||||
}
|
||||
|
||||
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
||||
|
||||
const languagePromises = repos.map((repo) =>
|
||||
fetch(repo.languages_url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then((res) => res.json() as Promise<Record<string, number>>),
|
||||
);
|
||||
|
||||
const repoLanguages = await Promise.all(languagePromises);
|
||||
const languages: GitHubLanguageStats = {};
|
||||
|
||||
repoLanguages.forEach((repoLang) => {
|
||||
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
||||
languages[lang] = (languages[lang] || 0) + bytes;
|
||||
});
|
||||
});
|
||||
|
||||
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
||||
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
||||
const totalGists = connection.user?.public_gists || 0;
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
stats: {
|
||||
repos,
|
||||
totalStars,
|
||||
totalForks,
|
||||
organizations,
|
||||
recentActivity,
|
||||
languages,
|
||||
totalGists,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to fetch GitHub stats', { error });
|
||||
toast.error('Failed to fetch GitHub statistics');
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubUserResponse;
|
||||
const newConnection: GitHubConnection = {
|
||||
user: data,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
};
|
||||
|
||||
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
||||
setConnection(newConnection);
|
||||
|
||||
await fetchGitHubStats(token);
|
||||
|
||||
toast.success('Successfully connected to GitHub');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to authenticate with GitHub', { error });
|
||||
toast.error('Failed to connect to GitHub');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
await fetchGithubUser(connection.token);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('Disconnected from GitHub');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) =>
|
||||
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
||||
}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
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',
|
||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`Enter your GitHub ${
|
||||
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
||||
}`}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-10 h-5" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
Required scopes:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!connection.user ? (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-purple-500 text-white',
|
||||
'hover:bg-purple-600',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug-x w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
{connection.user && (
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4" />
|
||||
Connected to GitHub
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && (
|
||||
<div className="p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-12 h-12 rounded-full" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.name}</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">@{connection.user.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingStats ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching GitHub stats...
|
||||
</div>
|
||||
) : (
|
||||
connection.stats && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Public Repos</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">{connection.user.public_repos}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Stars</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">{connection.stats.totalStars}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">Total Forks</p>
|
||||
<p className="text-lg font-medium text-bolt-elements-textPrimary">{connection.stats.totalForks}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h3>
|
||||
{connection.user.bio && (
|
||||
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:users w-4 h-4" />
|
||||
{connection.user.followers} followers
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-4 h-4" />
|
||||
{connection.stats.totalStars} stars
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-4 h-4" />
|
||||
{connection.stats.totalForks} forks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations Section */}
|
||||
{connection.stats.organizations.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{connection.stats.organizations.map((org) => (
|
||||
<a
|
||||
key={org.login}
|
||||
href={org.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
||||
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.recentActivity.map((event) => (
|
||||
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
||||
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
||||
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
||||
<span>on</span>
|
||||
<a
|
||||
href={`https://github.com/${event.repo.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-500 hover:underline"
|
||||
>
|
||||
{event.repo.name}
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
||||
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{new Date(connection.user.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">{connection.stats.totalGists}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{connection.stats.organizations.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
||||
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
||||
{Object.keys(connection.stats.languages).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories Section */}
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
||||
<div className="space-y-3">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
||||
{repo.name}
|
||||
</h5>
|
||||
{repo.description && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-branch w-3 h-3" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star w-3 h-3" />
|
||||
{repo.stargazers_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork w-3 h-3" />
|
||||
{repo.forks_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
273
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
273
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { netlifyConnection, isConnecting, isFetchingStats, updateNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import type { NetlifyUser, NetlifySite } from '~/types/netlify';
|
||||
|
||||
export function NetlifyConnection() {
|
||||
const connection = useStore(netlifyConnection);
|
||||
const connecting = useStore(isConnecting);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
|
||||
// Update the useEffect to handle the fetching state properly
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
if (connection.user && connection.token) {
|
||||
await fetchNetlifyStats(connection.token);
|
||||
}
|
||||
};
|
||||
fetchSites();
|
||||
}, [connection.user, connection.token]);
|
||||
|
||||
const fetchNetlifyStats = async (token: string) => {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!sitesResponse.ok) {
|
||||
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
||||
}
|
||||
|
||||
const sites = await sitesResponse.json() as NetlifySite[];
|
||||
|
||||
const currentState = netlifyConnection.get();
|
||||
updateNetlifyConnection({
|
||||
...currentState,
|
||||
stats: {
|
||||
sites,
|
||||
totalSites: sites.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Netlify API Error:', error);
|
||||
logStore.logError('Failed to fetch Netlify stats', { error });
|
||||
toast.error('Failed to fetch Netlify statistics');
|
||||
} finally {
|
||||
isFetchingStats.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
isConnecting.set(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const userData = await response.json() as NetlifyUser;
|
||||
updateNetlifyConnection({
|
||||
user: userData,
|
||||
token: connection.token,
|
||||
});
|
||||
|
||||
await fetchNetlifyStats(connection.token);
|
||||
toast.success('Successfully connected to Netlify');
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
logStore.logError('Failed to authenticate with Netlify', { error });
|
||||
toast.error('Failed to connect to Netlify');
|
||||
updateNetlifyConnection({ user: null, token: '' });
|
||||
} finally {
|
||||
isConnecting.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
updateNetlifyConnection({ user: null, token: '' });
|
||||
toast.success('Disconnected from Netlify');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
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 gap-2">
|
||||
<img className='w-5 h-5' height="24" width="24" crossOrigin='anonymous' src="https://cdn.simpleicons.org/netlify" />
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
||||
Personal Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
||||
disabled={connecting}
|
||||
placeholder="Enter your Netlify personal access token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-[#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(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-[#00AD9F] text-white',
|
||||
'hover:bg-[#00968A]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2',
|
||||
'text-white bg-red-800 border border-red-500',
|
||||
'hover:bg-red-50 dark:hover:bg-red-950',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
referrerPolicy='no-referrer'
|
||||
crossOrigin="anonymous"
|
||||
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>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2">
|
||||
<div className="i-ph:buildings w-4 h-4" />
|
||||
Your Sites ({connection.stats?.totalSites || 0})
|
||||
</h4>
|
||||
{connection.stats?.sites?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{connection.stats.sites.map((site) => (
|
||||
<a
|
||||
key={site.id}
|
||||
href={site.admin_url}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
||||
{site.name}
|
||||
</h5>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,186 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { toast } from 'react-toastify';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { path } from '~/utils/path';
|
||||
import { useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const { showChat } = useStore(chatStore);
|
||||
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.user || !connection.token) {
|
||||
toast.error('Please connect to Netlify first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: "netlify build",
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
}
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
// Get all files recursively
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isFile()) {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
// Remove /dist prefix from the path
|
||||
const deployPath = fullPath.replace(buildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(buildPath);
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${artifact.id}`);
|
||||
|
||||
// Deploy using the API route with file contents
|
||||
const response = await fetch('/api/deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteId: existingSiteId || undefined,
|
||||
files: fileContents,
|
||||
token: connection.token,
|
||||
chatId: artifact.id
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.site) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
// Poll for deployment status
|
||||
const maxAttempts = 20; // 2 minutes timeout
|
||||
let attempts = 0;
|
||||
let deploymentStatus;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${connection.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
deploymentStatus = await statusResponse.json() as any;
|
||||
|
||||
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (deploymentStatus.state === 'error') {
|
||||
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
attempts++;
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('Deployment timed out');
|
||||
}
|
||||
|
||||
// Store the site ID if it's a new site
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
Deployed successfully!{' '}
|
||||
<a
|
||||
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View site
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || !activePreview}
|
||||
onClick={handleDeploy}
|
||||
className='px-4 hover:bg-bolt-elements-item-backgroundActive'
|
||||
>
|
||||
{isDeploying ? 'Deploying...' : 'Deploy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||
<Button
|
||||
active={showChat}
|
||||
@@ -51,9 +216,10 @@ interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames('flex items-center p-1.5', {
|
||||
@@ -62,7 +228,9 @@ function Button({ active = false, disabled = false, children, onClick }: ButtonP
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
||||
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
disabled,
|
||||
})}
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user