feat: add netlify one-click deployment

This commit is contained in:
KevIsDev
2025-02-24 17:24:32 +00:00
parent 2a8472ed17
commit 4da13d1edc
9 changed files with 136 additions and 167 deletions

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
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';
@@ -74,8 +73,6 @@ export default function ConnectionsTab() {
tokenType: 'classic',
});
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
// Load saved connection on mount
useEffect(() => {
@@ -101,8 +98,6 @@ export default function ConnectionsTab() {
const fetchGitHubStats = async (token: string) => {
try {
setIsFetchingStats(true);
// Fetch repositories - only owned by the authenticated user
const reposResponse = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
@@ -184,59 +179,9 @@ export default function ConnectionsTab() {
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,
};
// Save connection
localStorage.setItem('github_connection', JSON.stringify(newConnection));
setConnection(newConnection);
// Fetch additional stats
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');
};
if (isLoading) {
return <LoadingSpinner />;
}
@@ -259,9 +204,9 @@ export default function ConnectionsTab() {
<div className="grid grid-cols-1 gap-4">
{/* GitHub Connection */}
<GithubConnection/>
<GithubConnection />
{/* Netlify Connection */}
<NetlifyConnection/>
<NetlifyConnection />
</div>
</div>
);

View File

@@ -553,4 +553,3 @@ export function GithubConnection() {
</motion.div>
);
}

View File

@@ -28,7 +28,7 @@ export function NetlifyConnection() {
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
@@ -37,8 +37,8 @@ export function NetlifyConnection() {
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
}
const sites = await sitesResponse.json() as NetlifySite[];
const sites = (await sitesResponse.json()) as NetlifySite[];
const currentState = netlifyConnection.get();
updateNetlifyConnection({
...currentState,
@@ -63,7 +63,7 @@ export function NetlifyConnection() {
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
'Authorization': `Bearer ${connection.token}`,
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
@@ -72,12 +72,12 @@ export function NetlifyConnection() {
throw new Error('Invalid token or unauthorized');
}
const userData = await response.json() as NetlifyUser;
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) {
@@ -105,7 +105,13 @@ export function NetlifyConnection() {
<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" />
<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>
@@ -113,9 +119,7 @@ export function NetlifyConnection() {
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
Personal Access Token
</label>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
@@ -190,12 +194,12 @@ export function NetlifyConnection() {
</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]"
<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>
@@ -231,7 +235,12 @@ export function NetlifyConnection() {
{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]">
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url}
</a>
{site.published_deploy && (
@@ -270,4 +279,4 @@ export function NetlifyConnection() {
</div>
</motion.div>
);
}
}

View File

@@ -16,7 +16,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const connection = useStore(netlifyConnection);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
@@ -31,26 +31,27 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
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",
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);
@@ -60,17 +61,21 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
// 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;
@@ -79,7 +84,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
Object.assign(files, subFiles);
}
}
return files;
}
@@ -96,12 +101,12 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
siteId: existingSiteId || undefined,
files: fileContents,
token: connection.token,
chatId: artifact.id
chatId: artifact.id,
}),
});
const data = await response.json() as any;
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');
@@ -114,35 +119,38 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
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}`,
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;
);
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));
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error('Status check error:', error);
attempts++;
await new Promise(resolve => setTimeout(resolve, 2000));
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);
@@ -151,15 +159,15 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
toast.success(
<div>
Deployed successfully!{' '}
<a
href={deploymentStatus.ssl_url || deploymentStatus.url}
target="_blank"
<a
href={deploymentStatus.ssl_url || deploymentStatus.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View site
</a>
</div>
</div>,
);
} catch (error) {
console.error('Deploy error:', error);
@@ -172,11 +180,11 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
return (
<div className="flex">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<Button
active
<Button
active
disabled={isDeploying || !activePreview}
onClick={handleDeploy}
className='px-4 hover:bg-bolt-elements-item-backgroundActive'
className="px-4 hover:bg-bolt-elements-item-backgroundActive"
>
{isDeploying ? 'Deploying...' : 'Deploy'}
</Button>
@@ -222,15 +230,17 @@ interface ButtonProps {
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return (
<button
className={classNames('flex items-center p-1.5', {
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'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
)}
className={classNames(
'flex items-center p-1.5',
{
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'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}