From f0ea22ec63b6b7c0a53913ebc5426bdab5cdb545 Mon Sep 17 00:00:00 2001 From: Leex Date: Sun, 23 Feb 2025 18:45:56 +0100 Subject: [PATCH 01/10] Update docker.yaml (stable/main deployment) Exteneded the workflow/action to also deploy a stable release container --- .github/workflows/docker.yaml | 76 +++++++++++++---------------------- 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 0b54001c..42070f9f 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -1,14 +1,11 @@ ---- name: Docker Publish on: - workflow_dispatch: push: branches: - main - tags: - - v* - - '*' + - stable + workflow_dispatch: permissions: packages: write @@ -16,66 +13,49 @@ permissions: env: REGISTRY: ghcr.io - DOCKER_IMAGE: ghcr.io/${{ github.repository }} - BUILD_TARGET: bolt-ai-production # bolt-ai-development + IMAGE_NAME: ${{ github.repository }} jobs: docker-build-publish: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v4 - - id: string - uses: ASzc/change-string-case-action@v6 - with: - string: ${{ env.DOCKER_IMAGE }} - - - name: Docker meta - id: meta - uses: crazy-max/ghaction-docker-meta@v5 - with: - images: ${{ steps.string.outputs.lowercase }} - flavor: | - latest=true - prefix= - suffix= - tags: | - type=semver,pattern={{version}} - type=pep440,pattern={{version}} - type=ref,event=tag - type=raw,value={{sha}} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Container Registry + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + - name: Extract metadata for Docker image + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image for main + if: github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: context: . - file: ./Dockerfile - target: ${{ env.BUILD_TARGET }} - platforms: linux/amd64,linux/arm64 push: true - tags: ${{ steps.meta.outputs.tags }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest - cache-to: type=inline - - name: Check manifest - run: | - docker buildx imagetools inspect ${{ steps.string.outputs.lowercase }}:${{ steps.meta.outputs.version }} - - - name: Dump context - if: always() - uses: crazy-max/ghaction-dump-context@v2 + - name: Build and push Docker image for stable + if: github.ref == 'refs/heads/stable' + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + labels: ${{ steps.meta.outputs.labels }} From 8e790d08e2146a0a39ac0f912833d6c095355449 Mon Sep 17 00:00:00 2001 From: Leex Date: Sun, 23 Feb 2025 22:21:55 +0100 Subject: [PATCH 02/10] Update Dockerfile - Test Bugfix Dockerpipeline the npm install -g corepack@latest is supposed to make problems with the main docker build --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 99e6f1b5..9a4dca95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app # Install dependencies (this step is cached as long as the dependencies don't change) COPY package.json pnpm-lock.yaml ./ -RUN npm install -g corepack@latest +#RUN npm install -g corepack@latest RUN corepack enable pnpm && pnpm install From 52970812cb486657e0de7c04e51949130f5febc4 Mon Sep 17 00:00:00 2001 From: Leex Date: Sun, 23 Feb 2025 22:28:24 +0100 Subject: [PATCH 03/10] Update Dockerfile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9a4dca95..1cd3f0bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ COPY package.json pnpm-lock.yaml ./ #RUN npm install -g corepack@latest -RUN corepack enable pnpm && pnpm install +#RUN corepack enable pnpm && pnpm install +RUN npm install -g pnpm && pnpm install # Copy the rest of your app's source code COPY . . From 7dda7938d4e8e924892f9e91b4d7554a19a7832a Mon Sep 17 00:00:00 2001 From: Leex Date: Sun, 23 Feb 2025 22:52:23 +0100 Subject: [PATCH 04/10] Update docker.yaml --- .github/workflows/docker.yaml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 42070f9f..fece8ba0 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -38,24 +38,12 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Build and push Docker image for main - if: github.ref == 'refs/heads/main' + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Build and push Docker image for stable - if: github.ref == 'refs/heads/stable' - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref == 'refs/heads/stable' && 'stable' || 'latest' }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} labels: ${{ steps.meta.outputs.labels }} From 2a8472ed17030111da43ce39a4f64ccc22130084 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Mon, 24 Feb 2025 17:24:00 +0000 Subject: [PATCH 05/10] 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 (
+
+ +
); diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index 8c1e9b8a..c9ee632b 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -553,4 +553,3 @@ export function GithubConnection() { ); } - \ No newline at end of file diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index 53c085d0..bdd08285 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -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() {
- +

Netlify Connection

@@ -113,9 +119,7 @@ export function NetlifyConnection() { {!connection.user ? (
- +
- {connection.user.full_name}

{connection.user.full_name}

@@ -231,7 +235,12 @@ export function NetlifyConnection() { {site.name}
- + {site.url} {site.published_deploy && ( @@ -270,4 +279,4 @@ export function NetlifyConnection() {
); -} \ No newline at end of file +} diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 4179c248..8539460a 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -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> { 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; @@ -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(
Deployed successfully!{' '} - View site -
+
, ); } catch (error) { console.error('Deploy error:', error); @@ -172,11 +180,11 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { return (
- @@ -222,15 +230,17 @@ interface ButtonProps { function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) { return ( )} @@ -347,42 +366,6 @@ export function GithubConnection() { )}
- {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 && (
@@ -399,6 +382,10 @@ export function GithubConnection() {
{connection.user.followers} followers + +
+ {connection.user.public_repos} public repos +
{connection.stats.totalStars} stars @@ -413,139 +400,163 @@ export function GithubConnection() { {/* Organizations Section */} {connection.stats.organizations.length > 0 && ( -
-

Organizations

-
- {connection.stats.organizations.map((org) => ( - - {org.login} - {org.login} - - ))} -
+
+ + {expandedSections.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} - - ))} -
+
+ + {expandedSections.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} - +
+ + {expandedSections.recentActivity && ( +
+ {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()} +
-
- {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) => ( - -
-
-
-
- {repo.name} -
- {repo.description && ( -

{repo.description}

- )} -
- -
- {repo.default_branch} - - - Updated {new Date(repo.updated_at).toLocaleDateString()} + + {expandedSections.repositories && ( +
+ {connection.stats.repos.map((repo) => ( + + )} @@ -553,3 +564,14 @@ export function GithubConnection() { ); } + +function LoadingSpinner() { + return ( +
+
+
+ Loading... +
+
+ ); +} \ No newline at end of file diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index bdd08285..920bf646 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; import { useStore } from '@nanostores/react'; @@ -11,8 +11,8 @@ export function NetlifyConnection() { const connection = useStore(netlifyConnection); const connecting = useStore(isConnecting); const fetchingStats = useStore(isFetchingStats); + const [isSitesExpanded, setIsSitesExpanded] = useState(false); - // Update the useEffect to handle the fetching state properly useEffect(() => { const fetchSites = async () => { if (connection.user && connection.token) { @@ -175,21 +175,21 @@ export function NetlifyConnection() {
- -
- Connected to Netlify - + +
+ Connected to Netlify +
@@ -214,11 +214,18 @@ export function NetlifyConnection() {
) : (
-

+

- {connection.stats?.sites?.length ? ( +
+ + {isSitesExpanded && connection.stats?.sites?.length ? (
{connection.stats.sites.map((site) => ( ))}
- ) : ( + ) : isSitesExpanded ? (
No sites found in your Netlify account
- )} + ) : null}
)}
diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 8539460a..d4ccb181 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -7,8 +7,9 @@ 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 { useEffect, useRef, useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import interface HeaderActionButtonsProps {} @@ -22,6 +23,20 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const [isDeploying, setIsDeploying] = useState(false); const isSmallViewport = useViewport(1024); const canHideChat = showWorkbench || !showChat; + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const currentChatId = useStore(chatId); const handleDeploy = async () => { if (!connection.user || !connection.token) { @@ -29,6 +44,11 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { return; } + if (!currentChatId) { + toast.error('No active chat found'); + return; + } + try { setIsDeploying(true); @@ -89,7 +109,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { } const fileContents = await getAllFiles(buildPath); - const existingSiteId = localStorage.getItem(`netlify-site-${artifact.id}`); + // Use chatId instead of artifact.id + const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); // Deploy using the API route with file contents const response = await fetch('/api/deploy', { @@ -101,7 +122,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { siteId: existingSiteId || undefined, files: fileContents, token: connection.token, - chatId: artifact.id, + chatId: currentChatId, // Use chatId instead of artifact.id }), }); @@ -153,7 +174,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { // Store the site ID if it's a new site if (data.site) { - localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id); + localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id); } toast.success( @@ -179,15 +200,76 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { return (
-
- +
+
+ +
+ + {isDropdownOpen && ( +
+ + + +
+ )}
{expandedSections.organizations && (
@@ -433,16 +432,18 @@ export function GithubConnection() { {/* Languages Section */}
- {expandedSections.languages && (
@@ -463,16 +464,18 @@ export function GithubConnection() { {/* Recent Activity Section */}
- {expandedSections.recentActivity && (
@@ -503,16 +506,18 @@ export function GithubConnection() { {/* Repositories Section */}
- {expandedSections.repositories && (
@@ -574,4 +579,4 @@ function LoadingSpinner() {
); -} \ No newline at end of file +} diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index 920bf646..490cb2d1 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -214,16 +214,18 @@ export function NetlifyConnection() {
) : (
- {isSitesExpanded && connection.stats?.sites?.length ? (
diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index d4ccb181..34a1e42c 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -33,6 +33,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { } } document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); @@ -109,6 +110,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { } const fileContents = await getAllFiles(buildPath); + // Use chatId instead of artifact.id const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); @@ -209,65 +211,64 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2" > {isDeploying ? 'Deploying...' : 'Deploy'} -
+
{isDropdownOpen && (
- - - + + +
)}
diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index ca46ef5d..57b23b87 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -5,7 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory'; export interface IChatMetadata { gitUrl: string; gitBranch?: string; - netlifySiteId?: string; // Add this field + netlifySiteId?: string; } const logger = createScopedLogger('ChatHistory'); From 23c22c5c126dcd03517b19044bc15cc05255b503 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Tue, 25 Feb 2025 19:02:03 +0000 Subject: [PATCH 10/10] fix: show netlify deployed link netlify deploy button to be disabled on streaming and show link icon when deployed --- .../tabs/connections/GithubConnection.tsx | 297 ++++++++---------- .../tabs/connections/NetlifyConnection.tsx | 44 +-- app/components/chat/BaseChat.tsx | 8 +- app/components/chat/Chat.client.tsx | 4 + app/components/chat/ChatAlert.tsx | 2 +- .../chat/NetlifyDeploymentLink.client.tsx | 51 +++ app/components/chat/ProgressCompilation.tsx | 1 - .../header/HeaderActionButtons.client.tsx | 14 +- app/lib/stores/netlify.ts | 36 +++ app/lib/stores/streaming.ts | 3 + 10 files changed, 255 insertions(+), 205 deletions(-) create mode 100644 app/components/chat/NetlifyDeploymentLink.client.tsx create mode 100644 app/lib/stores/streaming.ts diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index c6142834..e2d8924f 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -73,22 +73,13 @@ export function GithubConnection() { }); const [isLoading, setIsLoading] = useState(true); const [isConnecting, setIsConnecting] = useState(false); - const [expandedSections, setExpandedSections] = useState({ - organizations: false, - languages: false, - recentActivity: false, - repositories: false, - }); - - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections((prev) => ({ - ...prev, - [section]: !prev[section], - })); - }; + const [isFetchingStats, setIsFetchingStats] = useState(false); + const [isStatsExpanded, setIsStatsExpanded] = useState(false); 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', { @@ -165,6 +156,7 @@ export function GithubConnection() { logStore.logError('Failed to fetch GitHub stats', { error }); toast.error('Failed to fetch GitHub statistics'); } finally { + setIsFetchingStats(false); } }; @@ -188,7 +180,7 @@ export function GithubConnection() { setIsLoading(false); }, []); - if (isLoading) { + if (isLoading || isConnecting || isFetchingStats) { return ; } @@ -350,7 +342,7 @@ export function GithubConnection() { 'hover:bg-red-600', )} > -
+
Disconnect )} @@ -365,161 +357,144 @@ export function GithubConnection() { {connection.user && connection.stats && (
-
- {connection.user.login} -
-

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

- {connection.user.bio && ( -

{connection.user.bio}

- )} -
- -
- {connection.user.followers} followers - - -
- {connection.user.public_repos} public repos - - -
- {connection.stats.totalStars} stars - - -
- {connection.stats.totalForks} forks - -
-
-
- - {/* Organizations Section */} - {connection.stats.organizations.length > 0 && ( -
- - {expandedSections.organizations && ( -
- {connection.stats.organizations.map((org) => ( - - {org.login} - {org.login} - - ))} + - {expandedSections.languages && ( -
- {Object.entries(connection.stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([language]) => ( - - {language} - - ))} +
+ +
+ {connection.user.followers} followers + + +
+ {connection.user.public_repos} public repos + + +
+ {connection.stats.totalStars} stars + + +
+ {connection.stats.totalForks} forks + +
- )} -
+
+ - {/* Recent Activity Section */} -
- - {expandedSections.recentActivity && ( -
- {connection.stats.recentActivity.map((event) => ( -
-
-
- {event.type.replace('Event', '')} - on + {isStatsExpanded && ( +
+ {connection.stats.organizations.length > 0 && ( +
+

Organizations

+
+ {connection.stats.organizations.map((org) => ( - {event.repo.name} + {org.login} + {org.login} -
-
- {new Date(event.created_at).toLocaleDateString()} at{' '} - {new Date(event.created_at).toLocaleTimeString()} -
+ ))}
- ))} -
- )} -
+
+ )} - {/* Repositories Section */} -
- - {expandedSections.repositories && ( + {/* Languages Section */} +
+

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Recent Activity Section */} +
+
+ )}
)}
diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index 490cb2d1..5881b761 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -4,8 +4,14 @@ 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'; +import { + netlifyConnection, + isConnecting, + isFetchingStats, + updateNetlifyConnection, + fetchNetlifyStats, +} from '~/lib/stores/netlify'; +import type { NetlifyUser } from '~/types/netlify'; export function NetlifyConnection() { const connection = useStore(netlifyConnection); @@ -22,40 +28,6 @@ export function NetlifyConnection() { 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); diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index ff1f4184..08d34afb 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -45,6 +45,7 @@ interface BaseChatProps { showChat?: boolean; chatStarted?: boolean; isStreaming?: boolean; + onStreamingChange?: (streaming: boolean) => void; messages?: Message[]; description?: string; enhancingPrompt?: boolean; @@ -79,6 +80,7 @@ export const BaseChat = React.forwardRef( showChat = true, chatStarted = false, isStreaming = false, + onStreamingChange, model, setModel, provider, @@ -126,6 +128,10 @@ export const BaseChat = React.forwardRef( console.log(transcript); }, [transcript]); + useEffect(() => { + onStreamingChange?.(isStreaming); + }, [isStreaming, onStreamingChange]); + useEffect(() => { if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; @@ -337,7 +343,7 @@ export const BaseChat = React.forwardRef( }}
{ + streamingState.set(streaming); + }} enhancingPrompt={enhancingPrompt} promptEnhanced={promptEnhanced} sendMessage={sendMessage} diff --git a/app/components/chat/ChatAlert.tsx b/app/components/chat/ChatAlert.tsx index 674bbc80..5aeb08c7 100644 --- a/app/components/chat/ChatAlert.tsx +++ b/app/components/chat/ChatAlert.tsx @@ -24,7 +24,7 @@ export default function ChatAlert({ alert, clearAlert, postMessage }: Props) { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} - className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`} + className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`} >
{/* Icon */} diff --git a/app/components/chat/NetlifyDeploymentLink.client.tsx b/app/components/chat/NetlifyDeploymentLink.client.tsx new file mode 100644 index 00000000..da8e0b41 --- /dev/null +++ b/app/components/chat/NetlifyDeploymentLink.client.tsx @@ -0,0 +1,51 @@ +import { useStore } from '@nanostores/react'; +import { netlifyConnection, fetchNetlifyStats } from '~/lib/stores/netlify'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useEffect } from 'react'; + +export function NetlifyDeploymentLink() { + const connection = useStore(netlifyConnection); + const currentChatId = useStore(chatId); + + useEffect(() => { + if (connection.token && currentChatId) { + fetchNetlifyStats(connection.token); + } + }, [connection.token, currentChatId]); + + const deployedSite = connection.stats?.sites?.find((site) => site.name.includes(`bolt-diy-${currentChatId}`)); + + if (!deployedSite) { + return null; + } + + return ( + + + + { + e.stopPropagation(); // Add this to prevent click from bubbling up + }} + > +
+ + + + + {deployedSite.url} + + + + + + ); +} diff --git a/app/components/chat/ProgressCompilation.tsx b/app/components/chat/ProgressCompilation.tsx index 270fac03..68ae3388 100644 --- a/app/components/chat/ProgressCompilation.tsx +++ b/app/components/chat/ProgressCompilation.tsx @@ -42,7 +42,6 @@ export default function ProgressCompilation({ data }: { data?: ProgressAnnotatio 'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt', 'p-1', )} - style={{ transform: 'translateY(1rem)' }} >
(null); + const isStreaming = useStore(streamingState); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -41,7 +44,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const handleDeploy = async () => { if (!connection.user || !connection.token) { - toast.error('Please connect to Netlify first'); + toast.error('Please connect to Netlify first in the settings tab!'); return; } @@ -206,7 +209,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {