From 2a8472ed17030111da43ce39a4f64ccc22130084 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Mon, 24 Feb 2025 17:24:00 +0000 Subject: [PATCH] feat: add one-click netlify deployment --- .../tabs/connections/ConnectionsTab.tsx | 346 +---------- .../tabs/connections/GithubConnection.tsx | 556 ++++++++++++++++++ .../tabs/connections/NetlifyConnection.tsx | 273 +++++++++ .../header/HeaderActionButtons.client.tsx | 176 +++++- app/lib/runtime/action-runner.ts | 41 ++ app/lib/stores/netlify.ts | 27 + app/routes/api.deploy.ts | 223 +++++++ app/types/actions.ts | 6 +- app/types/netlify.ts | 41 ++ 9 files changed, 1343 insertions(+), 346 deletions(-) create mode 100644 app/components/@settings/tabs/connections/GithubConnection.tsx create mode 100644 app/components/@settings/tabs/connections/NetlifyConnection.tsx create mode 100644 app/lib/stores/netlify.ts create mode 100644 app/routes/api.deploy.ts create mode 100644 app/types/netlify.ts diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index caaedce5..663e5465 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -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() {
{/* GitHub Connection */} - -
-
-
-

GitHub Connection

-
- -
-
- - -
- -
- - 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', - )} - /> -
- - Get your token -
- - - - Required scopes:{' '} - {connection.tokenType === 'classic' - ? 'repo, read:org, read:user' - : 'Repository access, Organization access'} - -
-
-
- -
- {!connection.user ? ( - - ) : ( - - )} - - {connection.user && ( - -
- Connected to GitHub - - )} -
- - {connection.user && ( -
-
- {connection.user.login} -
-

{connection.user.name}

-

@{connection.user.login}

-
-
- - {isFetchingStats ? ( -
-
- Fetching GitHub stats... -
- ) : ( - connection.stats && ( -
-
-

Public Repos

-

- {connection.user.public_repos} -

-
-
-

Total Stars

-

- {connection.stats.totalStars} -

-
-
-

Total Forks

-

- {connection.stats.totalForks} -

-
-
- ) - )} -
- )} - - {connection.user && connection.stats && ( -
-
- {connection.user.login} -
-

- {connection.user.name || connection.user.login} -

- {connection.user.bio && ( -

{connection.user.bio}

- )} -
- -
- {connection.user.followers} followers - - -
- {connection.stats.totalStars} stars - - -
- {connection.stats.totalForks} forks - -
-
-
- - {/* Organizations Section */} - {connection.stats.organizations.length > 0 && ( -
-

Organizations

-
- {connection.stats.organizations.map((org) => ( - - {org.login} - {org.login} - - ))} -
-
- )} - - {/* Languages Section */} -
-

Top Languages

-
- {Object.entries(connection.stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([language]) => ( - - {language} - - ))} -
-
- - {/* Recent Activity Section */} -
-

Recent Activity

-
- {connection.stats.recentActivity.map((event) => ( -
-
-
- {event.type.replace('Event', '')} - on - - {event.repo.name} - -
-
- {new Date(event.created_at).toLocaleDateString()} at{' '} - {new Date(event.created_at).toLocaleTimeString()} -
-
- ))} -
-
- - {/* Additional Stats */} -
-
-
Member Since
-
- {new Date(connection.user.created_at).toLocaleDateString()} -
-
-
-
Public Gists
-
- {connection.stats.totalGists} -
-
-
-
Organizations
-
- {connection.stats.organizations.length} -
-
-
-
Languages
-
- {Object.keys(connection.stats.languages).length} -
-
-
- - {/* Existing repositories section */} -

Recent Repositories

-
); diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx new file mode 100644 index 00000000..8c1e9b8a --- /dev/null +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -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({ + 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>), + ); + + 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 ( + +
+
+
+

GitHub Connection

+
+ +
+
+ + +
+ +
+ + 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', + )} + /> +
+ + Get your token +
+ + + + Required scopes:{' '} + {connection.tokenType === 'classic' + ? 'repo, read:org, read:user' + : 'Repository access, Organization access'} + +
+
+
+ +
+ {!connection.user ? ( + + ) : ( + + )} + + {connection.user && ( + +
+ Connected to GitHub + + )} +
+ + {connection.user && ( +
+
+ {connection.user.login} +
+

{connection.user.name}

+

@{connection.user.login}

+
+
+ + {isFetchingStats ? ( +
+
+ Fetching GitHub stats... +
+ ) : ( + connection.stats && ( +
+
+

Public Repos

+

{connection.user.public_repos}

+
+
+

Total Stars

+

{connection.stats.totalStars}

+
+
+

Total Forks

+

{connection.stats.totalForks}

+
+
+ ) + )} +
+ )} + + {connection.user && connection.stats && ( +
+
+ {connection.user.login} +
+

+ {connection.user.name || connection.user.login} +

+ {connection.user.bio && ( +

{connection.user.bio}

+ )} +
+ +
+ {connection.user.followers} followers + + +
+ {connection.stats.totalStars} stars + + +
+ {connection.stats.totalForks} forks + +
+
+
+ + {/* Organizations Section */} + {connection.stats.organizations.length > 0 && ( +
+

Organizations

+
+ {connection.stats.organizations.map((org) => ( + + {org.login} + {org.login} + + ))} +
+
+ )} + + {/* Languages Section */} +
+

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Recent Activity Section */} +
+

Recent Activity

+
+ {connection.stats.recentActivity.map((event) => ( +
+
+
+ {event.type.replace('Event', '')} + on + + {event.repo.name} + +
+
+ {new Date(event.created_at).toLocaleDateString()} at{' '} + {new Date(event.created_at).toLocaleTimeString()} +
+
+ ))} +
+
+ + {/* Additional Stats */} +
+
+
Member Since
+
+ {new Date(connection.user.created_at).toLocaleDateString()} +
+
+
+
Public Gists
+
{connection.stats.totalGists}
+
+
+
Organizations
+
+ {connection.stats.organizations.length} +
+
+
+
Languages
+
+ {Object.keys(connection.stats.languages).length} +
+
+
+ + {/* Repositories Section */} +

Recent Repositories

+
+ {connection.stats.repos.map((repo) => ( + + + + ); +} + \ No newline at end of file diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx new file mode 100644 index 00000000..53c085d0 --- /dev/null +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -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 ( + +
+
+
+ +

Netlify Connection

+
+
+ + {!connection.user ? ( +
+
+ + 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', + )} + /> + + + +
+ ) : ( +
+
+
+ +
+ Connected to Netlify + + +
+
+ +
+ {connection.user.full_name} +
+

{connection.user.full_name}

+

{connection.user.email}

+
+
+ + {fetchingStats ? ( +
+
+ Fetching Netlify sites... +
+ ) : ( +
+

+
+ Your Sites ({connection.stats?.totalSites || 0}) +

+ {connection.stats?.sites?.length ? ( +
+ {connection.stats.sites.map((site) => ( + +
+
+
+
+ {site.name} +
+
+ + {site.url} + + {site.published_deploy && ( + <> + + +
+ {new Date(site.published_deploy.published_at).toLocaleDateString()} + + + )} +
+
+ {site.build_settings?.provider && ( +
+ +
+ {site.build_settings.provider} + +
+ )} +
+ + ))} +
+ ) : ( +
+
+ No sites found in your Netlify account +
+ )} +
+ )} +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index ac9382d6..4179c248 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -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> { + const files: Record = {}; + 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( +
+ Deployed successfully!{' '} + + View site + +
+ ); + } catch (error) { + console.error('Deploy error:', error); + toast.error(error instanceof Error ? error.message : 'Deployment failed'); + } finally { + setIsDeploying(false); + } + }; + return (
+
+ +